Signal flow
{orgId, repositoryId} resolution, normalization, fingerprinting, ClickHouse writes, optional forward. Anything stateful (issue lifecycle, incidents, correlation, symbolication) belongs to the consumer of the sink webhook (Autter cloud, or your own backend).
Tenancy & key scopes
Every ClickHouse row is keyed by(org_id, repository_id) and every query must filter on both. One repository is one unit of analysis; the ingest key carries the mapping, so a key is scoped to exactly one repo.
Two key scopes separate frontend and backend credentials:
- Server keys (
autter_rt_…, secret) — backends only, for OTLP endpoints and as the relay’s forwarding key. Sent as a bearer header. - Client keys (
autter_rtc_…, publishable) — shipped in frontend bundles for direct browser ingest. Restricted to/v1/browser, enforced against a per-key origin allow-list, tighter rate limits, and carried as a?key=query param becausesendBeaconcannot set headers. A leaked client key can at worst submit fake browser events for one repo — it can never read data or send OTLP.
ClickHouse tables
runtime_metrics_1m is pre-aggregated per minute; readers must SUM(...) GROUP BY because SummingMergeTree collapses rows at merge time, eventually. Percentiles come from sampled spans at query time — the rollup table stores only counts and duration sums.
Occurrences are aggregation-ready at write time
runtime_error_occurrences holds errors and warnings/info (severity column: fatal | error | warning | info) — one dataset, sliceable by severity, rather than separate pipelines. Alongside the raw fields, the ingester stores derived columns computed by the same normalizers the fingerprint hashes, so aggregations never re-parse stacks or routes in SQL:
Severity is deliberately not part of the fingerprint: the same defect reported as a warning in one code path and an error in another stays one group.
Retention philosophy: raw signal is short-lived; anything worth keeping long-term (issue summaries, incident history, learnings) is derived and stored by the sink consumer.
Schema evolution: the ingester migrates the database itself at boot — baseline
CREATE IF NOT EXISTS for fresh databases plus versioned, idempotent migrations tracked in <db>.schema_migrations for existing ones. Deploying a new ingester version is the schema deployment. Readers (dashboards, the Autter backend) should treat columns as additive-only within a major version.Fingerprinting
sha256(source + service + error_type + normalised_message + top_5_frames + normalised_route), truncated to 32 hex chars.
- Message normalization: quoted strings →
<str>, UUIDs →<uuid>, long hex →<hex>, numbers →<n>. - Frame normalization: query strings and line/column offsets stripped — minified bundle offsets shift every deploy; file + function name are stable.
- Route normalization: id-like path segments →
:id(/orders/812→/orders/:id).
OTLP mapping (traces)
Resource attributes:
Span-level:
- Error occurrence emitted when span status is
ERROR, or perexceptionevent (exception.type,exception.message,exception.stacktrace). routefromhttp.route, falling back tourl.path/http.target(query-stripped).status_codefromhttp.response.status_code/http.status_code.- Server spans aggregate into 1-minute usage rollups:
request_count,error_count(status ≥ 500 or span error),duration_sum_ms.
Browser payload (v1)
exception, unhandled_rejection, session_start, and track_event (carries a name; counted into runtime_metrics_1m as request_count on the synthetic route event:<name> — coarse usage counters, not an analytics event store).
Forbidden at the schema level (rejected/stripped): full URLs with query strings, cookies, DOM content, form values, request headers/bodies, emails.
Sink webhook (v1)
WhenAUTTER_SINK_URL is set, each ingest batch POSTs:

