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}