Skip to main content

cja/
eyes_manifest.rs

1//! Boot-time app manifest emission to Eyes.
2//!
3//! Telemetry shows what *happened*; the manifest tells Eyes what the app's
4//! *shape* is — every registered job type, every cron entry with its schedule,
5//! and the build version. Apps send one manifest at process start via
6//! [`send_boot_manifest`]; each stored manifest doubles as a boot/deploy
7//! marker on the Eyes server.
8//!
9//! Emission is fire-and-forget: it spawns a background task, logs a warning
10//! on failure, and never blocks or crashes app boot. When Eyes is not
11//! configured (`EYES_ORG_ID`/`EYES_APP_ID` unset — the same variables
12//! [`crate::setup::setup_tracing`] uses), it's a debug-logged no-op.
13//!
14//! # Intended call site
15//!
16//! Call once at boot, *after* constructing your job/cron registries (they
17//! don't exist yet when `setup_tracing` runs, which is why this is a separate
18//! explicit call):
19//!
20//! ```rust,ignore
21//! async fn run() -> cja::Result<()> {
22//!     let _eyes_handle = setup_tracing("my-app")?;
23//!     let app_state = AppState::from_env().await?;
24//!     let cron_registry = cron_registry(); // your CronRegistry setup
25//!
26//!     cja::eyes_manifest::send_boot_manifest::<Jobs, AppState>(
27//!         Some(env!("CARGO_PKG_VERSION")),
28//!         option_env!("VERGEN_GIT_SHA"),
29//!         Some(&cron_registry),
30//!     );
31//!
32//!     // ... spawn server/job/cron workers as usual
33//! }
34//! ```
35//!
36//! `app_version` and `git_sha` are explicit parameters: as a library, cja
37//! cannot read your crate's compile-time env, so source them at the app call
38//! site (e.g. `env!("CARGO_PKG_VERSION")` and `option_env!("VERGEN_GIT_SHA")`
39//! if you use [vergen](https://docs.rs/vergen), matching what
40//! [`crate::setup::setup_sentry`] uses for release detection). Pass `None` if
41//! you don't have them.
42
43use crate::app_state::AppState;
44use crate::jobs::registry::JobRegistry;
45
46pub use eyes_subscriber::{
47    AppManifest, CronEntry, ExpectedProcessRole, HttpMethod, HttpMonitor, ManifestError,
48};
49
50/// Build an [`AppManifest`] from a job registry and (optionally) a cron registry.
51///
52/// Job names come from [`JobRegistry::job_names`] (generated by
53/// `impl_job_registry!`). Cron schedule strings come from
54/// [`crate::cron::CronRegistry::entries`]: the `Duration` `Debug` form for
55/// interval schedules (e.g. `"300s"`) and the original expression for cron
56/// expression schedules.
57///
58/// Most apps should call [`send_boot_manifest`] instead; this is exposed for
59/// callers that want to inspect or customize the manifest before sending it
60/// with [`send_manifest`] (e.g. attaching a `base_url` and [`HttpMonitor`]
61/// declarations).
62#[cfg(feature = "cron")]
63#[must_use]
64pub fn build_boot_manifest<J, S>(
65    app_version: Option<&str>,
66    git_sha: Option<&str>,
67    cron_registry: Option<&crate::cron::CronRegistry<S>>,
68) -> AppManifest
69where
70    J: JobRegistry<S>,
71    S: AppState,
72{
73    let mut manifest = AppManifest::default()
74        .jobs(J::job_names().iter().map(ToString::to_string).collect())
75        .crons(
76            cron_registry
77                .map(|registry| {
78                    registry
79                        .entries()
80                        .into_iter()
81                        .map(|(name, schedule)| CronEntry {
82                            name: name.to_string(),
83                            schedule,
84                        })
85                        .collect()
86                })
87                .unwrap_or_default(),
88        );
89    if let Some(app_version) = app_version {
90        manifest = manifest.app_version(app_version);
91    }
92    if let Some(git_sha) = git_sha {
93        manifest = manifest.git_sha(git_sha);
94    }
95    manifest
96}
97
98/// Build an [`AppManifest`] from a job registry (no cron support compiled in).
99#[cfg(not(feature = "cron"))]
100#[must_use]
101pub fn build_boot_manifest<J, S>(app_version: Option<&str>, git_sha: Option<&str>) -> AppManifest
102where
103    J: JobRegistry<S>,
104    S: AppState,
105{
106    let mut manifest =
107        AppManifest::default().jobs(J::job_names().iter().map(ToString::to_string).collect());
108    if let Some(app_version) = app_version {
109        manifest = manifest.app_version(app_version);
110    }
111    if let Some(git_sha) = git_sha {
112        manifest = manifest.git_sha(git_sha);
113    }
114    manifest
115}
116
117/// Send this app's boot manifest to Eyes, fire-and-forget.
118///
119/// Builds an [`AppManifest`] (see [`build_boot_manifest`]) and spawns a
120/// background task that POSTs it using the `EYES_URL`/`EYES_ORG_ID`/
121/// `EYES_APP_ID` environment variables — the same configuration
122/// [`crate::setup::setup_tracing`] uses for the Eyes tracing layer.
123///
124/// This never blocks or fails app boot:
125///
126/// - If `EYES_ORG_ID` or `EYES_APP_ID` is unset, it's a debug-logged no-op.
127/// - If called outside a Tokio runtime, it logs a warning and does nothing.
128/// - If the send fails, the spawned task logs a warning.
129///
130/// See the [module docs](self) for the intended call site and how to source
131/// `app_version`/`git_sha`.
132#[cfg(feature = "cron")]
133pub fn send_boot_manifest<J, S>(
134    app_version: Option<&str>,
135    git_sha: Option<&str>,
136    cron_registry: Option<&crate::cron::CronRegistry<S>>,
137) where
138    J: JobRegistry<S>,
139    S: AppState,
140{
141    spawn_send(build_boot_manifest::<J, S>(
142        app_version,
143        git_sha,
144        cron_registry,
145    ));
146}
147
148/// Send this app's boot manifest to Eyes, fire-and-forget (no cron support
149/// compiled in). See the `cron`-enabled variant for behavior details.
150#[cfg(not(feature = "cron"))]
151pub fn send_boot_manifest<J, S>(app_version: Option<&str>, git_sha: Option<&str>)
152where
153    J: JobRegistry<S>,
154    S: AppState,
155{
156    spawn_send(build_boot_manifest::<J, S>(app_version, git_sha));
157}
158
159/// Send a caller-built manifest to Eyes, fire-and-forget.
160///
161/// Like [`send_boot_manifest`], but for apps that need declarations the
162/// registries can't express. Build the base manifest with
163/// [`build_boot_manifest`], attach the extra declarations with the
164/// [`AppManifest`] builder methods, then send:
165///
166/// ```rust,ignore
167/// let manifest = cja::eyes_manifest::build_boot_manifest::<Jobs, AppState>(
168///     Some(env!("CARGO_PKG_VERSION")),
169///     option_env!("VERGEN_GIT_SHA"),
170///     Some(&cron_registry),
171/// )
172/// .base_url("https://arena.battlesnake.com")
173/// .monitors(vec![HttpMonitor::new("health", "/health")]);
174/// cja::eyes_manifest::send_manifest(manifest);
175/// ```
176///
177/// Same guarantees as [`send_boot_manifest`]: never blocks or fails app boot,
178/// debug-logged no-op when `EYES_ORG_ID`/`EYES_APP_ID` are unset.
179pub fn send_manifest(manifest: AppManifest) {
180    spawn_send(manifest);
181}
182
183fn spawn_send(manifest: AppManifest) {
184    if std::env::var("EYES_ORG_ID").is_err() || std::env::var("EYES_APP_ID").is_err() {
185        tracing::debug!("Skipping Eyes boot manifest: EYES_ORG_ID and/or EYES_APP_ID not set");
186        return;
187    }
188
189    match tokio::runtime::Handle::try_current() {
190        Ok(handle) => {
191            handle.spawn(async move {
192                if let Err(error) = eyes_subscriber::send_manifest_from_env(&manifest).await {
193                    tracing::warn!(%error, "Failed to send Eyes boot manifest");
194                }
195            });
196        }
197        Err(_) => {
198            tracing::warn!("Skipping Eyes boot manifest: no Tokio runtime available");
199        }
200    }
201}
202
203#[cfg(all(test, feature = "cron"))]
204mod test {
205    use std::time::Duration;
206
207    use super::*;
208    use crate::cron::CronRegistry;
209    use crate::impl_job_registry;
210    use crate::jobs::Job;
211    use crate::server::cookies::CookieKey;
212
213    #[derive(Clone)]
214    struct TestAppState {
215        db: sqlx::PgPool,
216        cookie_key: CookieKey,
217    }
218
219    impl AppState for TestAppState {
220        fn db(&self) -> &sqlx::PgPool {
221            &self.db
222        }
223
224        fn version(&self) -> &'static str {
225            "test"
226        }
227
228        fn cookie_key(&self) -> &CookieKey {
229            &self.cookie_key
230        }
231    }
232
233    #[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
234    struct ManifestJobA;
235
236    #[async_trait::async_trait]
237    impl Job<TestAppState> for ManifestJobA {
238        const NAME: &'static str = "ManifestJobA";
239
240        async fn run(&self, _app_state: TestAppState) -> color_eyre::Result<()> {
241            Ok(())
242        }
243    }
244
245    #[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
246    struct ManifestJobB;
247
248    #[async_trait::async_trait]
249    impl Job<TestAppState> for ManifestJobB {
250        const NAME: &'static str = "ManifestJobB";
251
252        async fn run(&self, _app_state: TestAppState) -> color_eyre::Result<()> {
253            Ok(())
254        }
255    }
256
257    impl_job_registry!(TestAppState, ManifestJobA, ManifestJobB);
258
259    #[test]
260    fn test_build_boot_manifest_populates_jobs_and_crons() {
261        let mut cron_registry: CronRegistry<TestAppState> = CronRegistry::new();
262        cron_registry.register_job(ManifestJobA, None, Duration::from_mins(5));
263        cron_registry
264            .register_job_with_cron(ManifestJobB, None, "0 0 9 * * * *")
265            .unwrap();
266
267        let manifest = build_boot_manifest::<Jobs, TestAppState>(
268            Some("1.2.3"),
269            Some("abc123"),
270            Some(&cron_registry),
271        );
272
273        assert_eq!(manifest.app_version.as_deref(), Some("1.2.3"));
274        assert_eq!(manifest.git_sha.as_deref(), Some("abc123"));
275        assert_eq!(manifest.jobs, vec!["ManifestJobA", "ManifestJobB"]);
276        assert_eq!(
277            manifest.crons,
278            vec![
279                CronEntry {
280                    name: "ManifestJobA".to_string(),
281                    schedule: "300s".to_string(),
282                },
283                CronEntry {
284                    name: "ManifestJobB".to_string(),
285                    schedule: "0 0 9 * * * *".to_string(),
286                },
287            ]
288        );
289    }
290
291    #[test]
292    fn test_build_boot_manifest_without_cron_registry() {
293        let manifest = build_boot_manifest::<Jobs, TestAppState>(None, None, None);
294
295        assert_eq!(manifest.app_version, None);
296        assert_eq!(manifest.git_sha, None);
297        assert_eq!(manifest.jobs, vec!["ManifestJobA", "ManifestJobB"]);
298        assert!(manifest.crons.is_empty());
299    }
300}