Cap the AI session artifact download and say when it was cut

What changes for users

Values: 5,000 events and 15 MB, constants that are easy to raise later if needed. Shared with Nate in #634979.

  • A session download now stops at 5,000 audit events or 15 MB of event details, newest first. The file says the limits and whether and why it was cut.
  • Downloads within the limits are the same, apart from three new keys.
  • How many sessions on GitLab.com go over the limits is not measured: the events are in ClickHouse, which Database Lab cannot query, and the PostgreSQL table is still empty there.

The AI session artifact download now stops at 5,000 audit events or 15 MB of event details, newest first. The JSON file records the limits and whether and why it was cut. Exports within the limits are unchanged apart from three new keys.

This is the "Download" action in the session drawer of the AI Governance Audit events tab, on groups and projects. Stacked on Load AI audit event details after the ClickHous... (!265815): it targets that branch and shows only its own commit. Once !265815 merges it is rebased onto master and retargeted, and the diff does not change.

Why

download (Duo sessions) and download_session (external agent sessions) in Security::AgentArtifactsDownloadable build the whole file with Gitlab::Json.dump and send_data. Both export services load every audit event of the session and parse each details. The web worker holds the rows, the parsed hashes and the final string at once, so memory grows with the size of the session.

External agent events are capped at 10 KB of details on ingest. Duo events are not yet (#603352+), and a session has no cap on its number of events.

The change

  • MAX_AUDIT_EVENTS = 5_000 and MAX_AUDIT_EVENTS_DETAILS_BYTES = 15.megabytes on Ai::DuoWorkflows::SessionArtifactExportService. The Governance subclass inherits them. Both finders take limit(MAX_AUDIT_EVENTS + 1), so one extra row shows the export was cut without a count query.
  • Events are serialized newest first in batches of 500. The byte budget sums each event's details size and stops once it is passed. On ClickHouse, details are loaded per batch through ClickHouseFinder.with_details from Load AI audit event details after the ClickHous... (!265815), so memory stays within the budget plus one batch. On PostgreSQL the records come with their details, so there the event cap bounds memory and the budget only trims the file.
  • The file gains audit_events_limits, audit_events_truncated and audit_events_truncated_by ("events", "details_bytes" or null).
  • Specs cover a cut by events, a cut by bytes, and an export within limits on both paths. The controller specs check the new keys. The memory claim is shown by a ClickHouse spec with the batch size set to 1 and a 20-byte budget over three events: the details lookup runs twice (once per consumed batch), the third batch is never fetched, and the file says details_bytes.

A cut file starts like this:

{
  "session": { "...": "..." },
  "audit_events_limits": { "events": 5000, "details_bytes": 15728640 },
  "audit_events_truncated": true,
  "audit_events_truncated_by": "details_bytes",
  "audit_events": [ "..." ]
}
Queries, before and after

Both export queries already run today; this MR adds LIMIT 5001 (the cap plus one row) to each. Nothing else in them changes, and no index changes: the limit sits on the same ORDER BY created_at DESC, id DESC that the queries already had.

PostgreSQL (AuditEvents::AiAuditEvents::PostgresqlFinder, a Duo session), before:

SELECT "ai_audit_events".* FROM "ai_audit_events"
WHERE "ai_audit_events"."workflow_id" = 12345
ORDER BY "ai_audit_events"."created_at" DESC, "ai_audit_events"."id" DESC

After:

SELECT "ai_audit_events".* FROM "ai_audit_events"
WHERE "ai_audit_events"."workflow_id" = 12345
ORDER BY "ai_audit_events"."created_at" DESC, "ai_audit_events"."id" DESC LIMIT 5001

The index (workflow_id, created_at DESC, id DESC) already serves the filter and the order, so the limit only stops the scan earlier. On GitLab.com the table is still empty: a Database Lab run of the after query on 2026-10-30 scanned all 13 partitions (this query has no created_at bound) and returned 0 rows; with empty partitions the planner picks sequential scans, so the index choice cannot be shown until data exists.

ClickHouse (AuditEvents::AiAuditEvents::ClickHouseFinder, a Duo session; an external session filters on ai_governance_session_id with the session's start bound instead), before:

SELECT id, workflow_id, ai_governance_session_id, created_at, traversal_path, event_name, author_id, ip_address
FROM (
  SELECT id, workflow_id, any(ai_governance_session_id) AS ai_governance_session_id, created_at, traversal_path,
         any(event_name) AS event_name, any(author_id) AS author_id, any(ip_address) AS ip_address
  FROM ai_audit_events
  WHERE workflow_id = 12345
  GROUP BY traversal_path, workflow_id, created_at, id
) ai_audit_events
ORDER BY created_at DESC, id DESC

After: the same query with LIMIT 5001 on the outer select. The dedup GROUP BY still runs over the session's rows, which Load AI audit event details after the ClickHous... (!265815) made cheap by leaving details out of it; the limit bounds the rows returned to the export and so the number of details lookups (at most 10 batches of 500, since the export keeps the first 5,000 rows).

Decisions

  • A cap over streaming or a background job. The two limits bound memory by themselves, and the drawer link stays synchronous. A job would need a notification flow for a file most users open right away.
  • 15 MB matches TARGET_FILESIZE of the CSV exports, so there is one size rule. 5,000 events is unmeasured on production and is a named constant to tune. It bounds the page query and the number of details lookups, which the byte budget alone does not.
  • Newest events stay, matching the order the page shows. Keeping the oldest was rejected because the latest activity is what the dashboard surfaces.
  • Three explicit keys over a note in the events array, so a script checks one boolean and one reason.

Out of scope

Related to #635324.

Edited by Dheeraj Joshi

Merge request reports

Loading
Loading