diff --git a/apps/ingest/src/main.rs b/apps/ingest/src/main.rs index e3006dfddd..21e390e8ae 100644 --- a/apps/ingest/src/main.rs +++ b/apps/ingest/src/main.rs @@ -4306,7 +4306,11 @@ async fn handle_vercel_audit_trail_inner( warn!(org_id = %org_id, skipped, "Vercel audit drain events without an action were skipped"); } let count = store_audit_trail_rows(pipeline, &org_id, rows).await?; - state.resolver.note_source_use(&resolved_key, false); + // The delivery is the integration's, not the key's default source: its health reads this. + let claimed: Vec = sources::source_uuid_from_public_id(claim) + .into_iter() + .collect(); + state.resolver.note_claimed_source_use(&claimed); Ok(count) } @@ -13455,6 +13459,34 @@ mod tests { drop(std::fs::remove_dir_all(&queue_dir)); } + // The integration's health reads its own source's last use; the key's default + // source has nothing to say about whether the audit drain delivers. + #[tokio::test] + async fn vercel_audit_drain_records_use_of_the_integrations_source() { + let queue_dir = unique_main_test_dir("vercel-audit-trail-source-use"); + let (state, store, _imports) = + audit_trail_test_state("maple_sk_test_use", "org_use", queue_dir.clone()).await; + + vercel_audit_delivery( + &state, + &vercel_drain_headers("maple_sk_test_use", AUDIT_SOURCE_ID), + ) + .await + .expect("the drain's delivery should be accepted"); + + for _ in 0..50 { + if !store.source_uses.lock().unwrap().is_empty() { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert_eq!( + *store.source_uses.lock().unwrap(), + vec![AUDIT_SOURCE_UUID.to_owned()] + ); + drop(std::fs::remove_dir_all(&queue_dir)); + } + // The drain Maple creates sends the private key and names the connection's // source. Any other sender could write the org an audit event it made up. #[tokio::test] diff --git a/apps/ingest/src/telemetry.rs b/apps/ingest/src/telemetry.rs index 81babf62db..9827a8f3ff 100644 --- a/apps/ingest/src/telemetry.rs +++ b/apps/ingest/src/telemetry.rs @@ -3295,8 +3295,12 @@ impl ExportWorker { // Shed, not drop-and-forget: a shed frame still advances the cursor and // releases its org bytes below, because leaving it in the WAL would keep // the very backlog this is cutting. + // An audit event is a record, not a reading that ages out: a late one is still + // evidence, and its volume is bounded per org at the accept path. let is_stale = |frame: &QueuedFrame| { - !max_age.is_zero() && now.duration_since(frame.enqueued_at) > max_age + !max_age.is_zero() + && frame.signal != TelemetrySignal::AuditTrail + && now.duration_since(frame.enqueued_at) > max_age }; let mut shed_frames = 0usize; let mut shed_rows = 0usize; @@ -6806,6 +6810,56 @@ mod tests { drop(std::fs::remove_dir_all(queue_dir)); } + #[tokio::test] + async fn an_audit_event_past_the_max_queue_age_is_still_exported() { + let (primary_url, mut primary_rx) = spawn_fake_tinybird().await; + + let queue_dir = unique_test_dir("queue-age-audit-trail"); + let mut cfg = test_cfg(); + cfg.endpoint = primary_url; + cfg.queue_dir = queue_dir.clone(); + cfg.wal_shards = 1; + cfg.batch_max_wait = Duration::from_millis(1); + // Any frame that survives a channel hop is already older than this. + cfg.queue_max_age = Duration::from_nanos(1); + + let pipeline = TelemetryPipeline::new( + cfg, + Client::builder() + .timeout(Duration::from_secs(5)) + .build() + .unwrap(), + ) + .await + .unwrap(); + + let event: serde_json::Map = serde_json::from_str( + r#"{"id":"evt_1","teamId":"team_1","action":"project.created","timestamp":1700000000000}"#, + ) + .unwrap(); + let row = crate::audit_trail::vercel_event(&event, 1_700_000_060_000) + .expect("an event with an action") + .to_row("org_late", 1_700_000_060_000); + pipeline + .accept_rows_to( + "org_late", + crate::audit_trail::DATASOURCE.to_owned(), + vec![row], + TelemetrySignal::AuditTrail, + ExportDestination::Tinybird, + ) + .await + .unwrap(); + + tokio::time::timeout(Duration::from_secs(2), primary_rx.recv()) + .await + .expect("an audit event past the max queue age must still be exported") + .expect("fake Tinybird channel should stay open"); + assert_eq!(pipeline.drain_wal(Duration::from_secs(5)).await, 0); + + drop(std::fs::remove_dir_all(queue_dir)); + } + #[tokio::test] async fn drain_wal_reports_the_backlog_when_the_destination_is_unreachable() { let queue_dir = unique_test_dir("drain-stuck"); diff --git a/apps/landing/src/content/docs/integrations/gcp.md b/apps/landing/src/content/docs/integrations/gcp.md index 5f9cdd3ac1..8e8f273a49 100644 --- a/apps/landing/src/content/docs/integrations/gcp.md +++ b/apps/landing/src/content/docs/integrations/gcp.md @@ -76,7 +76,7 @@ Turning something off takes effect in Maple at once. Google Cloud keeps the sink ### Log filter -Platform, request and audit logs are forwarded. Admin Activity and Policy Denied audit entries do not land in logs: they go to the audit log in the Security section, which only organization admins can read. System Event audit entries stay in logs, and so do Kubernetes API writes (`k8s.io`), which are mostly a cluster's own controllers. On a self-hosted gateway that forwards telemetry to a collector, every audit entry stays a log. The default filter leaves out what costs a lot and says little: +Platform, request and audit logs are forwarded. Admin Activity and Policy Denied audit entries do not land in logs: they go to the audit log in the Security section, which only organization admins can read. System Event audit entries stay in logs, and so do Kubernetes API writes (`k8s.io`), which are mostly a cluster's own controllers. A self-hosted gateway does the same only when it writes to the warehouse itself and `INGEST_AUDIT_TRAIL_ENABLED` is `true`; without the setting, or when it forwards telemetry to a collector, every audit entry stays a log. The default filter leaves out what costs a lot and says little: | Left out | Reason | | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | diff --git a/apps/landing/src/content/docs/integrations/vercel.md b/apps/landing/src/content/docs/integrations/vercel.md index 9149058fee..4046dc1e14 100644 --- a/apps/landing/src/content/docs/integrations/vercel.md +++ b/apps/landing/src/content/docs/integrations/vercel.md @@ -82,7 +82,7 @@ Audit events do not arrive as logs. They go to the audit log in the Security sec Each event shows who acted (name, email and id), the action as Vercel names it, such as `drain-created`, what it was done to, the time, the IP address and user agent, and the event's payload as Vercel sent it. A payload over 32 KB is not kept. Maple accepts audit events only from the drain it creates on your team; a drain you add by hand on Vercel is refused. -A self-hosted gateway that forwards telemetry to a collector has no audit log table. There, audit events arrive as logs under the `vercel-audit-log` service. +A self-hosted gateway stores audit events in the audit log only when it writes to the warehouse itself and `INGEST_AUDIT_TRAIL_ENABLED` is `true`. Without the setting, or when the gateway forwards telemetry to a collector, audit events arrive as logs under the `vercel-audit-log` service, where every member can read them. ## AI Gateway @@ -109,7 +109,7 @@ The trace drain forwards these spans to Maple. You do not need to configure an e ## Where the data shows -- **Infrastructure → Vercel → Overview**: requests, error rate, cache hit rate and cold-start rate across the team, AI Gateway spend by project, recent builds and failed builds, and, when those drains are on, Web Vitals, page views and audit log events. +- **Infrastructure → Vercel → Overview**: requests, error rate, cache hit rate and cold-start rate across the team, AI Gateway spend by project, recent builds and failed builds, and, when those drains are on, Web Vitals and page views. Audit events are in **Security → Audit log**. - **Infrastructure → Vercel → Projects**: each project with its last build, requests, 5xx rate, cache hit rate, cold-start rate and latency. Click a project for its own page. - **A project's page** has a tab for each part: - **Traffic**: requests by status class, top paths with their cache hit rate, and top errors. @@ -121,7 +121,7 @@ The trace drain forwards these spans to Maple. You do not need to configure an e A section whose drain is off says so and links to the setting that turns it on. - **Service map**: each project with its outbound calls, from its function spans, and an edge from `vercel.edge-network` to every project it invokes. A project's Infrastructure tab links to its Vercel project page. -- [Logs](/docs/explore/logs): filter by `service.name` (the project name) or `vercel.project.id`. Speed Insights, Web Analytics and audit log events are under their own `service.name`. +- [Logs](/docs/explore/logs): filter by `service.name` (the project name) or `vercel.project.id`. Speed Insights and Web Analytics are under their own `service.name`. - [Traces](/docs/explore/traces): your `@vercel/otel` service, the deployment traces, and `ai-gateway`. - **Dashboards**: the **Vercel** template charts requests, cache, errors and AI Gateway usage. diff --git a/docs/ingest-wal-durability.md b/docs/ingest-wal-durability.md index a593adf57b..0878d7c315 100644 --- a/docs/ingest-wal-durability.md +++ b/docs/ingest-wal-durability.md @@ -160,7 +160,8 @@ Two limits close that gap: and releases its org bytes, or it would keep the backlog it was shed to cut. Frames recovered by WAL replay are stamped at replay time, so a restart's backlog gets a full window to export in rather than being shed on sight for having been written before the restart. Set to 0 to queue - without an age bound. + without an age bound. Audit trail frames are exempt: an audit event is a record, a late one is + still wanted, and its volume is bounded per org where it is accepted. - **An open ClickHouse target circuit breaker** defers the batch, with the lane cursor held, for one window: one breaker cooldown, at most 30s and at most a quarter of the max queue age. A target blip shorter than that is delivered without loss. Past the window that org's batch is