Last Updated: 2026-07-19 · Self-hosted production operations: Postgres backend, projects / tiers / quotas, retention / pruning, webhooks & alerts, and export / cold storage.
This guide is for operators running AEP in production as a self-hosted deployment (AEP is maintained as a reference implementation — see the project direction note in README.md). It picks up where SETUP.md (integration) and SECURITY.md (hardening) leave off and focuses on storage, multi-tenancy, and data-lifecycle controls:
- Choosing a storage backend
- Postgres production deployment
- Projects, tiers & quotas
- Retention & pruning runbook
- Phase 13 production checklist
- Webhooks & alerts
- Export & cold storage
- Known limitations (reference implementation)
Everything below is verified against the source. Where a value comes from code,
the file is cited (e.g. src/tiers.js) so you can confirm it yourself.
Endpoint examples use the unversioned root paths (e.g. POST /webhooks); every
consumer-facing endpoint is also served under the /v1 prefix, and all
responses carry an API-Version: 1 header, while /admin/* and the infra
probes (/health, /ready, /metrics/prometheus) are unversioned.
AEP speaks to its database through a single StorageBackend interface
(src/db/backends/interface.js), so the rest of the server is identical no
matter which engine you run. Two backends ship today, selected by the
STORAGE_BACKEND environment variable:
| Backend | STORAGE_BACKEND |
Best for | Connection setting |
|---|---|---|---|
| SQLite | sqlite (default) |
Local dev, single-node, low volume | DATABASE_PATH (default ./data/aep.db) |
| Postgres | postgres |
Production, durability, higher throughput | DATABASE_URL (or PG* libpq vars) |
Switching engines requires no code changes — only STORAGE_BACKEND plus the
matching connection variable. The two backends are kept at strict behavioural
parity by the Postgres parity tests CI job, which runs the full integration
suite against a real postgres:16 container.
STORAGE_BACKEND=sqlite DATABASE_PATH=./data/aep.db npm run ingestSQLite stores everything in one file and survives restarts. It is the right choice for development and small single-node deployments, but it is a single-writer database: it does not scale across multiple server instances and a busy ingest path can contend with a concurrent pruning job (see §4). For anything production-grade, use Postgres.
STORAGE_BACKEND=postgres \
DATABASE_URL=postgres://aep:secret@db.internal:5432/aep \
npm run ingestWhen DATABASE_URL is unset, the pg driver falls back to the standard libpq
environment variables (PGHOST / PGPORT / PGUSER / PGPASSWORD /
PGDATABASE) — useful when your platform injects those directly
(src/db/backends/postgres.js).
Both backends run their schema setup on db.init() at startup — CREATE TABLE IF NOT EXISTS …, CREATE INDEX IF NOT EXISTS …, and INSERT … ON CONFLICT DO NOTHING for seed rows. There is no separate migration step to run: point the
server at an empty database and it will create the events, sessions,
api_keys, projects, and server_metrics tables (plus the seeded default
project) on first start. Re-running against an existing database is a no-op. The
GET /ready probe returns 200 only once init() has completed and the schema is
present — wire it to a Kubernetes readinessProbe.
postgres://<user>:<password>@<host>:<port>/<database>
# e.g.
postgres://aep:s3cret@db.internal:5432/aep
Pass it as DATABASE_URL. Keep the password out of source control — inject it
from a secrets manager / Kubernetes Secret, never the committed .env.
The bundled docker-compose.yml runs the server against a SQLite volume. To run
Postgres instead, add a db service and switch the app's env. A minimal overlay:
services:
db:
image: postgres:16
environment:
POSTGRES_USER: aep
POSTGRES_PASSWORD: ${PGPASSWORD:?set PGPASSWORD}
POSTGRES_DB: aep
volumes:
- aep_pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U aep -d aep"]
interval: 5s
timeout: 3s
retries: 10
aep:
build: .
depends_on:
db:
condition: service_healthy
environment:
STORAGE_BACKEND: postgres
DATABASE_URL: postgres://aep:${PGPASSWORD}@db:5432/aep
ADMIN_TOKEN: ${ADMIN_TOKEN:?set ADMIN_TOKEN}
DASHBOARD_TOKEN: ${DASHBOARD_TOKEN:?set DASHBOARD_TOKEN}
ports:
- "${HOST_PORT:-8787}:8787"
volumes:
aep_pgdata:The server creates its schema on first boot, so no init container or migration
job is needed. For a managed Postgres (RDS, Cloud SQL, Neon, Supabase, …), drop
the db service and point DATABASE_URL at the managed instance's connection
string — append ?sslmode=require if the provider mandates TLS (the pg driver
honours libpq-style query parameters).
PostgresBackend opens a single pg connection Pool per process
(src/db/backends/postgres.js). Notes for sizing it:
- Pool size: the pool uses
pg's defaults (max 10 connections per process). Plan total Postgresmax_connectionsas (server instances × 10) + headroom for the prune job and any external clients (e.g. PgBouncer, psql). - Multi-instance: each server replica is its own pool. Behind a connection pooler (PgBouncer in transaction mode), a few hundred app connections collapse onto a small server-side pool — recommended past a handful of replicas. But see §8 before running more than one replica: several application features (SSE, rate limits, quotas, metrics) degrade with instance count even though the database layer scales fine.
- Transactions: ingest (
insertEvent) and pruning (pruneEventsBefore) each run inside a singleBEGIN … COMMITand check out one pooled client for the duration, so a backlog of either can briefly saturate the pool — size with headroom for ingest bursts. - Timestamps are stored as ISO-8601
TEXT, notTIMESTAMPTZ. This is deliberate: lexicographic ordering equals chronological ordering, which keeps cursor pagination and the retention cutoff comparison byte-identical to SQLite. Don't "fix" these columns to a timestamp type — it would break parity.
A project is the unit that owns ingested data and carries a subscription
tier. A seeded default project (enterprise / unlimited) absorbs all legacy keys
and data, so a single-tenant deployment that never touches the projects API
behaves exactly as it did pre-Phase-13.
curl -s -X POST http://localhost:8787/admin/projects \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"Acme Prod","tenantId":"acme","tier":"team"}'tier is one of free | team | enterprise (defaults to free). The tier's
default event_quota and retention_days are materialised onto the project row
at creation. You can override either per-project in the request body
(src/server.js → POST /admin/projects):
# Per-project overrides: a number sets the limit; null means unlimited.
-d '{"tenantId":"acme","tier":"team","eventQuota":2000000,"retentionDays":60}'The create response returns the project record; GET /admin/projects and
GET /admin/projects/:id additionally include the project's current usage. List
and inspect:
curl -s http://localhost:8787/admin/projects -H "Authorization: Bearer $ADMIN_TOKEN"
curl -s http://localhost:8787/admin/projects/<id> -H "Authorization: Bearer $ADMIN_TOKEN"Pass projectId when minting a key. Keys minted without it land in the default
project:
curl -s -X POST http://localhost:8787/admin/keys \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"tenantId":"acme","projectId":"<project-id>","label":"acme-prod","scopes":["read","write"]}'Every event ingested with that key is metered against — and retained according to — its project.
Built-in defaults (src/tiers.js):
| Tier | event_quota |
retention_days |
|---|---|---|
free |
100,000 | 30 |
team |
5,000,000 | 90 |
enterprise |
unlimited | unlimited (keep forever) |
The PRD frames free as "unlimited events up to 5 GB". This repo meters by event count, not bytes, so the free tier is translated to a conservative finite event quota so the quota path is enforceable and testable.
Every default is overridable via environment variables — set these on the server
(and on the prune job, since it reads retention_days) so limits can be tuned
without a code change. A value of unlimited (case-insensitive) or empty maps to
no limit:
TIER_FREE_EVENT_QUOTA=100000
TIER_FREE_RETENTION_DAYS=30
TIER_TEAM_EVENT_QUOTA=5000000
TIER_TEAM_RETENTION_DAYS=90
TIER_ENTERPRISE_EVENT_QUOTA=unlimited
TIER_ENTERPRISE_RETENTION_DAYS=unlimitedNote: env overrides change the tier defaults applied to projects created afterwards. Projects already created keep the
event_quota/retention_daysvalues stored on their row; change those via per-project overrides at creation time (a future PR may add an update endpoint).
When a project reaches its event_quota, POST /events returns HTTP 429 with
a Retry-After: 3600 header (src/middleware/quota.js):
{
"accepted": false,
"error": "Event quota exceeded for this project",
"quota": 100000,
"usage": 100000,
"project": "<project-id>"
}Clients should back off and retry after the indicated interval (the official
SDKs already honour Retry-After). This is distinct from the per-key rate
limit 429 (RATE_LIMIT_RPM, documented in SETUP.md §16) — same status code,
different cause: rate limiting throttles request frequency; the quota caps
total stored events for the project.
QUOTA_ENFORCEMENT(defaulttrue): set tofalseto record usage but never block ingest — useful for a staged rollout before turning enforcement on.QUOTA_REFRESH_MS(default10000): how stale the in-memory per-project usage counter may get before it re-counts against the database.- Metered by
tenant_id, notproject_id. Events predate projects and carrytenant_id; quota (and pruning) are scoped by the project's tenant. This is exact when one project owns a tenant — the expected model. - Event count, not bytes. The limit is a number of accepted events, not storage size.
- Soft, single-node limit. The usage counter is per-process and in-memory, so with multiple replicas the effective ceiling can drift by up to one refresh window × instance count. Hard global enforcement would need a shared counter (Redis / a per-ingest DB upsert) — out of scope for Phase 13.
A project can declare the region its data should reside in — EU / US /
APAC / global — at creation:
curl -s -X POST http://localhost:8787/admin/projects \
-H "Authorization: Bearer $ADMIN_TOKEN" -H 'Content-Type: application/json' \
-d '{"tenantId":"acme","tier":"team","region":"EU"}'The deployment declares where its storage actually lives via the
DATA_RESIDENCY_REGION env var. Each project response then carries:
region— the project's declared region (null= unspecified), andregionEnforced—trueonly whenDATA_RESIDENCY_REGIONsatisfies the project's declared region (or it asks for none /global). Afalseflag means the data is NOT physically in the region the project requires — monitor it as a residency-violation signal.
When DATA_RESIDENCY_REGION is set, exported audit bundles also record
data_residency_region in their signed manifest, so the evidence is
self-describing about where the data resided.
Important — this is a control, not routing. AEP does not route storage by region: a single deployment writes to one backend, so
DATA_RESIDENCY_REGIONis your attestation of that backend's location andregionEnforcedis a mismatch signal. True multi-region residency requires infrastructure — separate per-region ingest endpoints + storage — which is outside the application layer. Run one AEP deployment per region and point each region's tenants at the matching endpoint.
Phase 13 stores each project's retention_days but deliberately ships no
always-on scheduler. Retention is enforced by an operator-invoked, run-once
job (src/prune.js → src/retention.js). One pass:
- Lists every project.
- For each project with a positive, finite
retention_days, computes a cutoff ofnow − retention_daysand deletes events older than it (scoped to the project'stenant_id), inside a single transaction. - Reconciles the derived
sessionssummaries for the affected sessions only — deletes now-empty sessions and recomputesevent_count/started_at/updated_atfor the rest.
It uses the same storage backend as the server (STORAGE_BACKEND +
DATABASE_PATH / DATABASE_URL), so it prunes the live database.
npm run prune # delete expired events for all projects
npm run prune -- --dry-run # report what WOULD be deleted, change nothing
npm run prune -- --json # machine-readable summary on stdout
npm run prune -- --export-before-prune # archive expired events to cold storage, THEN delete
npm run prune -- --help # usage
--export-before-prunearchives each project's expired events to cold storage (S3 or a local directory) before deleting them, and skips deletion for any project whose export fails. See §7 Export & cold storage.
--dry-run JSON summary shape:
{
"dryRun": true,
"projects_scanned": 1,
"projects_pruned": 0,
"events_deleted": 0,
"sessions_deleted": 0,
"details": []
}Always
--dry-runfirst against a new schedule or after changing a retention policy, and read thedetails[]per-project breakdown before letting the real run delete anything.
- A project with
retention_daysNULL / 0 / negative means keep forever and is never pruned. This covers the seededdefaultenterprise project, so a default deployment prunes nothing until you create finite-retention projects. - Pruning is scoped by
tenant_id(carried over from quota metering, seesrc/retention.js). Exact when one project owns a tenant. If a single tenant ever has multiple projects with different retention windows, the shortest applicable window would over-prune the others' data — avoid mapping multiple differing-retention projects onto one tenant until per-project event tagging lands.
A key minted with only a tenantId (no projectId) binds to the seeded
default project, but its events are stored under the key's tenant_id. So
a tenant can have events while having no project row of its own — and with
no project there is no retention_days policy, so such a tenant is never
pruned (data quietly accumulates forever).
The prune job cannot fix this on its own (there is no retention window to apply),
but it no longer hides it: pruneAll discovers the distinct tenants present
in the events table and logs a warn line — "retention: tenants have events but no project …" — listing them, and reports them in the summary's
orphan_tenants array (the aep prune CLI prints a ⚠️ line on stderr). The
fix is operational: create a project for that tenant (POST /admin/projects
with its tenantId + a tier) so its retention policy applies on the next run.
See the export job (§7) for covering the same tenants' data in cold storage.
- Idempotent. Re-running prune is safe: a second run immediately after a first finds nothing newly past the cutoff and deletes nothing (until time advances). The job short-circuits when zero events match.
- Structured logs. The job logs one structured line per project it touches —
project_id,tenant_id,retention_days,cutoff,events_deleted,sessions_deleted,dry_run— atinfolevel via Pino. Scrape these to confirm the schedule ran and how much it removed. - Exit codes.
0on success,1on failure — wire alerting to a non-zero exit / a missing success log.
Run nightly at 03:15. Pass the same backend env the server uses so the job prunes the live database. For Postgres (recommended in production):
# /etc/cron.d/aep-prune — nightly retention pass at 03:15
15 3 * * * aep cd /opt/agent-event-protocol && \
STORAGE_BACKEND=postgres \
DATABASE_URL='postgres://aep:secret@db.internal:5432/aep' \
/usr/bin/npm run prune >> /var/log/aep-prune.log 2>&1For SQLite, swap the two DB vars for DATABASE_PATH=/opt/agent-event-protocol/data/aep.db.
Because SQLite is single-writer, schedule the SQLite prune during a low-ingest
window to avoid lock contention with the running server — another reason to
prefer Postgres in production.
Secrets in a crontab file are readable by anyone who can read the file. Prefer sourcing them from an env file with restricted permissions (
EnvironmentFile=under systemd, or. /etc/aep/prune.envin the cron line).
Reuse the server image (it contains src/prune.js) and point it at the same
DB Secret the server Deployment uses. Override the container command to run the
prune job instead of the server:
apiVersion: batch/v1
kind: CronJob
metadata:
name: aep-prune
namespace: aep
spec:
schedule: "15 3 * * *" # nightly 03:15 UTC
concurrencyPolicy: Forbid # never overlap two prune runs
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 3
jobTemplate:
spec:
backoffLimit: 1
activeDeadlineSeconds: 1800
template:
spec:
restartPolicy: Never
containers:
- name: prune
image: aep-ingest:latest # same image as the server
command: ["node", "src/prune.js"] # run the job, not the server
# args: ["--dry-run"] # uncomment to validate first
env:
- name: STORAGE_BACKEND
value: "postgres"
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: aep-db # the SAME secret the server uses
key: database-url
# Mirror any TIER_*_RETENTION_DAYS overrides set on the server so
# the job reads the same policy for projects created afterwards.Notes:
concurrencyPolicy: Forbidplus the job's own short-circuit-on-zero behaviour keeps runs from piling up if one is slow.- Validate with
--dry-run(uncomment theargsline) and check the pod logs — the structured per-project line tells you exactly what a real run would delete:kubectl -n aep logs job/<job-name>. - This CronJob deploys the AEP server image. The
operator/directory is a separate concern — it injects instrumentation sidecars into your agent pods and does not deploy the AEP server or the prune job.
The Phase 13 infrastructure shipped in full — Postgres backend (PR-B), projects / tiers / quotas (PR-C), retention / pruning (PR-D), and these ops docs (PR-E) — and this checklist remains current for any self-hosted production deployment. Before going live:
- Backend:
STORAGE_BACKEND=postgreswithDATABASE_URL(orPG*) pointed at a durable, backed-up Postgres. ConfirmGET /readyreturns 200. - Auth:
ADMIN_TOKEN,DASHBOARD_TOKEN, andMETRICS_TOKENset; server behind TLS. (See SECURITY.md.) - Projects: real projects created on the right tiers; production API keys
bound to their project via
projectId(not the seededdefault). - Quotas:
QUOTA_ENFORCEMENT=trueonce validated;TIER_*overrides set to match your plan; clients handle429+Retry-After. - Retention:
npm run prunescheduled (cron or k8s CronJob) against the same DB; validated with--dry-run; the prune job has the sameTIER_*retention env as the server; success/failure is alerted on. - Observability:
/metrics/prometheusscraped with theMETRICS_TOKENbearer credential (Prometheusauthorizationscrape config — in production the endpoint returns 503 until the token is set; see SECURITY.md §8); prune job logs collected. - Signature enforcement: the server accepts only payload-covering
canon:"v2"per-event signatures — legacy v1 (envelope-only) signatures are rejected with401, and (as of issue #65 Phase E) there is noREQUIRE_CANON_V2escape hatch to re-accept them. Confirm your emitters sign v2 (the published SDK default). See AUTH.md for the accept/reject matrix.
AEP can POST ingested events to external HTTP endpoints ("webhooks") so you can trigger alerts or downstream automation. This is the project's only outbound network feature, so it is off by default and hardened against SSRF.
Registration (POST /webhooks) works at any time, but no event is delivered
anywhere until you opt in:
WEBHOOKS_ENABLED=true # 1/true/yes/on — without this, deliveries are a no-opA fresh deploy therefore never starts POSTing to third parties unexpectedly.
Delivery runs off the ingest hot path (fire-and-forget after the 202), so a
slow or failing webhook never adds latency to, or fails, an ingest.
POST /webhooks (write-scoped key) with a target_url, an optional
event_types filter (["*"] for all, or a subset of the 12 core event types),
and an optional enabled flag:
curl -X POST https://your-aep/webhooks \
-H "Authorization: Bearer $WRITE_KEY" -H "Content-Type: application/json" \
-d '{"target_url":"https://hooks.example.com/aep","event_types":["error.raised","task.failed"]}'
# → 201 { "id":"wh_…", …, "signing_secret":"whsec_…" } ← store the secret NOWThe 201 response includes a one-time signing_secret — it is shown only here
and never returned again (GET/list omit it). Manage webhooks with GET /webhooks,
GET /webhooks/:id, PATCH /webhooks/:id (toggle enabled / change
filter/URL), DELETE /webhooks/:id, or the aep webhooks … CLI.
Every target URL is validated by a dedicated guard (src/ssrf.js) both at
registration and again at delivery time (DNS can rebind in between). Blocked by
default:
- non-
http(s)schemes, and URLs with embedded credentials (user:pass@…); - loopback (
127.0.0.0/8,::1), the unspecified address, link-local (169.254.0.0/16— including the169.254.169.254cloud-metadata endpoint), RFC1918 private ranges, CGNAT (100.64.0.0/10), IPv6 ULA/link-local, and other reserved ranges (IPv4-mapped IPv6 and the NAT6464:ff9b::/96prefix are decoded so they can't smuggle a private v4); - inherently-local hostnames (
localhost,*.internal,*.local, …).
At delivery the host is re-resolved and the socket is pinned to a validated IP, closing the DNS-rebind window. Self-hosters who must target an internal service (or a localhost listener in tests) can allow specific hosts:
WEBHOOK_TARGET_ALLOWLIST=alerts.svc.internal,127.0.0.1:9099 # host or host:portAn allowlisted host bypasses the private-range block but still must use
http(s) and still cannot carry credentials.
A matching event is POSTed with bounded exponential-backoff retries. Transient
failures (5xx, 408, 429, network errors, timeouts) are retried; other 4xx
are permanent (no retry). Every dimension is bounded — there is no unbounded
queue:
| Env var | Default | Hard max | Meaning |
|---|---|---|---|
WEBHOOK_MAX_RETRIES |
4 |
10 |
retries after the first attempt |
WEBHOOK_TIMEOUT_MS |
5000 |
30000 |
per-attempt request timeout |
WEBHOOK_MAX_CONCURRENT |
10 |
100 |
max concurrent in-flight deliveries |
WEBHOOK_BACKOFF_BASE_MS |
1000 |
— | exponential backoff base |
WEBHOOK_BACKOFF_MAX_MS |
30000 |
— | backoff ceiling per wait |
Each delivery is HMAC-SHA256-signed with the webhook's signing_secret (the same
canonical-JSON + HMAC stack as event signatures) and carries:
X-AEP-Signature: hmac-sha256=<base64 digest>
X-AEP-Webhook-Id / X-AEP-Delivery-Id / X-AEP-Event-Type
Verify by recomputing the HMAC over the raw request body you received and
comparing in constant time — see examples/verify-webhook-signature.js. Guard
against replay by deduping on the body's delivery_id and rejecting a stale
delivered_at. (Webhooks created before signing was added have no secret and are
delivered unsigned.)
Every attempt set is recorded in webhook_deliveries (status
pending/success/failed, attempt count, last HTTP code, last error). Inspect
it via GET /webhooks/:id/deliveries (read-scoped; since/until/limit), the
aep webhooks deliveries <id> CLI, or the dashboard Webhooks tab.
Caveat: like the access log, webhook_deliveries rows are not pruned by
the retention job — manage growth at the storage layer on high-volume
deployments. Concurrency is bounded per node (the in-memory semaphore), so the
limit is per-process, not cluster-wide.
AEP can stream a tenant's event log to durable cold storage — a local directory or an S3 bucket — as JSON Lines, CSV, or Apache Parquet, optionally compressed. Like the prune job this is an operator-invoked, run-once job with no always-on scheduler: run it on demand, or on a cron / k8s CronJob schedule.
The event envelope is the archival unit (sessions are derived summaries, rebuildable from events), which is also exactly what the retention job deletes — so export integrates cleanly as export-before-prune (below).
npm run export # project tenants → ./exports (jsonl, gzip)
npm run export -- --all-tenants # also tenants with events but no project
npm run export -- --tenant dev # one tenant
npm run export -- --since 2026-01-01T00:00:00Z --until 2026-04-01T00:00:00Z
npm run export -- --format parquet # jsonl | csv | parquet
npm run export -- --compression brotli # none | gzip | brotli (text formats)
npm run export -- --sink s3 --bucket my-archive --region us-east-1
npm run export -- --dry-run --json # report what would be written
npm run export -- --helpOne object is written per tenant (<prefix>/<tenant>/aep-events-<tenant>-<ts>.<ext>);
tenants with no events in the window are skipped. Export is scoped by
tenant_id (same caveat as quota/retention). It uses the same storage
backend as the server (STORAGE_BACKEND + DATABASE_PATH / DATABASE_URL).
A full run (no --tenant) enumerates tenants from the project registry. A
key's tenant_id can differ from its bound project's, so a tenant may have
events but no project row (see §4). By default such "orphan" tenants are
not exported — they are reported in the summary's orphan_tenants and a
⚠️ warning is printed on stderr so the silent miss is visible. To cover them:
npm run export -- --all-tenants(orEXPORT_ALL_TENANTS=1) unions the distinctevents.tenant_idvalues into the export set, so orphan tenants are archived alongside project-backed ones; ornpm run export -- --tenant <id>to export one orphan tenant on demand.
Events with a NULL tenant_id (untagged) are still not reachable by a full
run even with --all-tenants (there is no single-tenant slice for them); export
each by creating tenant-scoped keys, or wait for per-project event tagging.
| Format | Extension | Compression |
|---|---|---|
jsonl |
.jsonl |
external: none / gzip / brotli |
csv |
.csv |
external: none / gzip / brotli |
parquet |
.parquet |
internal (per-column GZIP) — external --compression does not apply |
Both the export job and export-before-prune read the same EXPORT_* env vars
(CLI flags override them on npm run export):
| Variable | Default | Meaning |
|---|---|---|
EXPORT_SINK |
local |
local or s3 |
EXPORT_OUT |
./exports |
base directory for the local sink |
EXPORT_S3_BUCKET |
— | bucket name (required for s3) |
EXPORT_S3_REGION |
AWS_REGION |
S3 region |
EXPORT_S3_ENDPOINT |
— | S3-compatible endpoint (MinIO etc.; path-style) |
EXPORT_FORMAT |
jsonl |
jsonl / csv / parquet |
EXPORT_COMPRESSION |
gzip |
none / gzip / brotli |
EXPORT_PREFIX |
(none) | key prefix within the sink |
- S3 is off unless explicitly selected (
--sink s3/EXPORT_SINK=s3); the default is the local filesystem and loads no cloud SDK. - Credentials are never passed as flags and never logged. The S3 client
resolves them from the standard AWS credential chain — env vars
(
AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY/AWS_SESSION_TOKEN), shared config, SSO, or (preferred in-cluster) an IAM role via IRSA / instance profile. Grant the role least-privileges3:PutObjecton the archive prefix only. - Enable bucket encryption (SSE-S3 / SSE-KMS) and a restrictive bucket policy; cold-storage exports are full event payloads.
To archive expired events to cold storage before the retention job deletes
them, add --export-before-prune (or PRUNE_EXPORT_BEFORE_DELETE=1) to the
prune job:
EXPORT_SINK=s3 EXPORT_S3_BUCKET=my-aep-archive AWS_REGION=us-east-1 \
npm run prune -- --export-before-pruneFor each project with a finite retention window, the job exports the
soon-to-be-deleted events (those with time < cutoff) to the configured sink,
then deletes them. Safety gate: if a project's export fails, that project's
events are not deleted — the failure is recorded in the summary
(export_failures, per-project export_error) and the job exits non-zero, so a
cold-storage outage can never cause unbacked data loss. --dry-run exports
nothing and deletes nothing.
The export window (
until = cutoff) matches the prune predicate exactly, so the archived object contains precisely the rows that are deleted. (A backdated event landing between export and delete is the only gap; events normally carry atimenear now, well after a past cutoff.)
apiVersion: batch/v1
kind: CronJob
metadata:
name: aep-prune-archive
spec:
schedule: "15 3 * * *" # nightly 03:15
concurrencyPolicy: Forbid
jobTemplate:
spec:
backoffLimit: 2
template:
metadata:
annotations:
eks.amazonaws.com/role-arn: arn:aws:iam::<acct>:role/aep-archive # IRSA
spec:
restartPolicy: Never
containers:
- name: prune
image: <your-aep-image>
command: ["npm", "run", "prune", "--", "--export-before-prune"]
env:
- name: STORAGE_BACKEND
value: postgres
- name: DATABASE_URL
valueFrom: { secretKeyRef: { name: aep-db, key: url } }
- name: EXPORT_SINK
value: s3
- name: EXPORT_S3_BUCKET
value: my-aep-archive
- name: EXPORT_FORMAT
value: parquet
# AWS credentials come from the IRSA role above — none in env.The crontab equivalent mirrors §4's recipe with
--export-before-prune added and the EXPORT_* vars exported in the cron
environment. Alert on a non-zero exit (export failure blocked a prune) or a
missing success log.
Constraints of the current implementation that shape how you deploy and operate it. Each is deliberate scope, not an accident; where a workaround exists it is noted. As elsewhere in this guide, every claim is verified against the cited source file.
Several runtime mechanisms live in per-process memory with no shared store: SSE
fan-out and the one-time SSE tickets (the sseClients / sseTickets maps,
src/server.js), the per-key ingest rate-limit windows
(src/middleware/rateLimit.js), the per-project quota usage cache
(src/middleware/quota.js — its own header documents the drift), and the
Prometheus counters (src/metrics.js). Behind a load balancer this means: a
dashboard connected to one replica misses events ingested on the others, the
effective per-key rate limit multiplies by the instance count, an SSE ticket
minted on one replica is unknown to the rest (the ticket → /stream two-step
needs sticky sessions), and each replica exports only its own metrics. Ingest
and read correctness are unaffected on the Postgres backend (see
§2 for pool sizing across replicas) — what
degrades is real-time delivery, limit/quota precision, and metrics locality.
The recommended posture is one instance scaled vertically; scale out only if
you accept those degradations (or front them with sticky sessions), since the
shared state that would fix them (a Redis-backed rate-limit/quota counter and
an SSE broker) is out of scope for the reference implementation.
Delivery is scheduled fire-and-forget on a microtask off the ingest path
(scheduleDelivery, src/webhookDelivery.js), and the webhook_deliveries
row is written only once an attempt actually starts — there is no durable
outbox, and nothing re-drives pending rows at startup. A restart therefore
drops deliveries that were queued or mid-retry (an interrupted delivery is
visible afterwards as a row stuck in pending; one that never started leaves no
row at all). Duplicates are also possible: a timed-out attempt that in fact
reached the receiver is retried, so receivers should de-duplicate on the
X-AEP-Delivery-Id header. Do not treat webhooks as a reliable event bus: use
them as a low-latency signal and reconcile by polling
GET /webhooks/:id/deliveries (§6) or
re-querying the read API.
SQLite evolves through versioned migrations tracked in a schema_migrations
table (src/db/migrate.js); Postgres instead applies a single hand-maintained,
idempotent SCHEMA_DDL string on every boot (src/db/backends/postgres.js)
with no migration tracking of its own. Every new SQLite migration must be
mirrored into that string by hand, and drift is not detected automatically —
the Postgres parity tests CI job (§1)
catches behavioural divergence only on paths the integration suite exercises.
When reviewing a change that adds a migration, check that the matching
SCHEMA_DDL edit is present.
GET /dashboard — and, when the dashboard token is used, the read endpoints —
accepts ?token= as an alternative to the Authorization header so a browser
can simply navigate to the page (extractBearerOrQuery, src/auth.js). The
application never logs query strings (the access log records the path only, and
/stream uses one-time SSE tickets precisely to keep credentials out of that
URL), but the token can still persist in browser history and in the access logs
of any reverse proxy or load balancer in front. Prefer the Bearer header for
programmatic access, avoid logging query strings at your proxy, and rotate
DASHBOARD_TOKEN if a URL may have leaked.
On SIGTERM / SIGINT the server stops accepting connections and waits for
in-flight requests, but open /stream (SSE) connections are never proactively
closed — with any SSE client connected (typically a dashboard),
httpServer.close() cannot complete and
shutdown falls through to the 30 s hard timeout, exiting with code 1
("shutdown timeout exceeded — forcing exit", src/server.js). Expect rolling
restarts to take the full 30 s per instance while dashboards are open, and
don't alert on that exit path alone. The dashboard reconnects automatically
after the drop (it exchanges a fresh SSE ticket on error), so the visible
impact is a brief gap in live updates.
See also: SETUP.md (integration & API reference), SECURITY.md (hardening), AUTH.md (keys, tenants, HMAC), and CHANGELOG.md (version history).