Exporting usage to a data warehouse
loom export-analytics writes one UTC day of usage and a snapshot of the namespaces to an
S3 bucket as Parquet files, one file per entity per day, for a data warehouse to load. The
Helm chart runs it once a day as a CronJob (analyticsExport in values.yaml). To query a
tenant's memories, with their content, and its request ledger in place, use the
SQL surface instead.
loom export-analytics --bucket analytics-landing --prefix loom \
--role-arn arn:aws:iam::123456789012:role/loom-analytics-writer \
--expected-bucket-owner 123456789012
It needs LOOM_ANALYTICS_EXPORT_NAME_KEY, the secret that names are hashed under (see
hashed names).
--day YYYY-MM-DD exports another day; the default is yesterday (UTC). A day that has not
ended is refused.
Files
<prefix>/<entity>/<yyyy>/<mm>/<dd>/<entity>-<yyyy>-<mm>-<dd>.parquet entity: usage, namespaces
Each upload carries If-None-Match: *, so an object is never replaced. A second run of a
day keeps the first run's files and prints already there, kept for each; a failed or
repeated run can be retried as it is. To export a day again, delete its objects first.
| Entity | One row per | Columns |
|---|---|---|
usage |
org, namespace, agent, operation, kind, transport, traffic_class of the day, from loom.agent_events |
day, ops, errors, server_errors, slow, input_tokens, output_tokens, embedding_tokens, llm_calls, cost_usd, bytes_written, latency_p50_ms, latency_p95_ms, latency_p99_ms, users, sessions, snapshot_at |
namespaces |
org, namespace registered in loom.namespaces or holding a live memory row |
registered, kind, visibility, active, created_on, live_memories, last_memory_on, day, snapshot_at |
kind in usage is request for a request, worker for a worker pass, verdict for a
deferred memory.ask consistency check and settle for a deferred conflict settle. Every
/v1/me/* route is one operation, me: the ledger names those rows after the request
path, which can carry any string a caller sent. users and sessions are distinct counts
within the row that leave out the - of a call that named none; a sum over rows counts a
user once per row. slow and the latency quantiles are the SLI rollup's: they count the
ok requests that rollup reads (an operation with an SLO class), slow those past their
latency budget (a budget of 0 makes every one slow), and a row with none has latencies of 0.
A /v1/me/* route, a deferred judgement or a worker pass counts in neither. The
namespaces file is the state at snapshot_at, whatever --day says: a backfill of an
old day gets that day's usage and today's namespaces. An unregistered namespace has
registered = false and NULL registry columns. created_on and last_memory_on are days,
not times: an exact time beside a hashed personal namespace could be matched to the person
who signed up or wrote then. Rows are sorted by the hashed names, never by the names
themselves.
What never leaves Loom
The columns are an allowlist (server/insights/store/analytics_export.py; the integration
test pins each entity's exact column list). A file in the bucket is outside
erasure, so the export carries no memory
content and no stored person identifier: no memory keys or values, no args, no user_id,
session_id, subject, owner_email or request id. Users and sessions go out as counts
only.
Hashed names
Org, namespace and agent names can name a person: a personal namespace is named after its
owner's email, self-signup builds an agent's namespace and name from the OS username and
host, and an MCP client names its agent. So org, namespace and agent always leave as
the lowercase hex of the reference SipHash-2-4-128 of the name under a secret key.
Each name is hashed on its own, so one string has one hash wherever it appears: a namespace name hashes the same in every org, and an org, a namespace and an agent with the same name share a hash. The warehouse can link those rows without the key, for example the same personal namespace in two orgs.
- The key is a random string of 32 or more characters in
LOOM_ANALYTICS_EXPORT_NAME_KEY; the export refuses to run with a shorter or empty one, or one with leading or trailing whitespace (a Secret made--from-filekeeps the file's newline, which would key every hash on it). -
The 16-byte SipHash key is the first 16 bytes of SHA-256 of that string (UTF-8). The hash is ClickHouse's
sipHash128ReferenceKeyed, which any SipHash-2-4-128 library reproduces from those 16 bytes, so a system that holds the key hashes its own ids to join on them. ClickHouse takes the key as two words, each 8-byte half read little-endian. Hash the id as the same string Loom stores (toStringof a UUID or a number): SipHash of a typed column hashes its binary form, which never matches.WITH SHA256({key:String}) AS d SELECT lower(hex(sipHash128ReferenceKeyed( (reinterpretAsUInt64(substring(d, 1, 8)), reinterpretAsUInt64(substring(d, 9, 8))), toString(tenant_id)))) AS hashed_tenant_id FROM tenants
- A hash is a pseudonym, not an anonymous value: whoever holds the key can recompute it from a name they know. Erasing a namespace in Loom does not remove its rows already in the bucket.
- A hash is stable while the key is. A new key changes every hash, and a day already exported keeps its old files: delete them before exporting it again under the new key.
- A query that uses the key records it, whether the key is bound as a parameter or written
as a literal: ClickHouse substitutes bound values into the query text in
system.query_logand writes them to its debug and trace logs. On the warehouse, restrict who reads those logs on the server that runs the join, or hash the ids outside ClickHouse with any SipHash-2-4-128 library. On Loom's side the export's key words land in the same logs of Loom's ClickHouse, whose readers can already read the names.
Credentials
The command uses the default AWS credentials of its process, and checks them once before
any ClickHouse work: with --role-arn by assuming the role, without one by asking STS who
they belong to (GetCallerIdentity). Missing or expired credentials, or a role that cannot
be assumed, fail there, not after the read; a missing s3:PutObject grant, a wrong bucket
or a bucket owner other than --expected-bucket-owner fails at the first upload. With
--role-arn it assumes the role again before each upload and writes under its credentials.
Assuming a writer role fits a bucket in another account: the bucket's account grants
s3:PutObject to the writer role, and the writer role trusts only the export's own
identity. The export needs no read, list or delete permission on the bucket; a bucket
encrypted with a customer-managed KMS key also needs kms:GenerateDataKey on that key for
the writer role, or every upload is refused. --expected-bucket-owner makes S3 refuse the
write when another account owns the bucket.
Pass --region (the bucket's, analyticsExport.region in the chart) unless the process
already has one from AWS_DEFAULT_REGION or the AWS config file; boto3 does not read
AWS_REGION here.
On Kubernetes
analyticsExport:
enabled: true
bucket: analytics-landing
prefix: loom
roleArn: arn:aws:iam::123456789012:role/loom-analytics-writer
expectedBucketOwner: "123456789012"
nameKeySecret:
name: loom-analytics-export-key # key LOOM_ANALYTICS_EXPORT_NAME_KEY
serviceAccount:
annotations:
eks.amazonaws.com/role-arn: arn:aws:iam::210987654321:role/loom-analytics-export
kubectl create secret generic loom-analytics-export-key \
--from-literal=LOOM_ANALYTICS_EXPORT_NAME_KEY="$(openssl rand -hex 32)"
Only the export's pod reads that Secret. The chart refuses to render without
nameKeySecret.name, and refuses whatever would hand the key to every web and workers pod:
LOOM_ANALYTICS_EXPORT_NAME_KEY in env, extraEnv or secrets.extra,
analytics_export_name_key in settingsFile (a ConfigMap every pod mounts), and a
nameKeySecret.name that is the release's Secret (secrets.existingSecret or the chart's
own) or an envFrom Secret. It cannot see the contents of those Secrets: keep the key out of
them.
The chart creates a ServiceAccount <fullname>-analytics-export and a CronJob of the same
name, where <fullname> is the chart's full name: fullnameOverride when set, else the
release name when it contains the chart name (nameOverride, else loom), else
<release>-<chart name>, cut to its first 35 characters so the name stays within 52
(kubectl get sa shows it). The job runs at 00:30 UTC (schedule), one run at a time,
with up to 3 retries and a three-hour deadline over all attempts; each statement may run
20 minutes, and the ClickHouse client may retry one, so a slow day can get fewer than four
attempts. A refusal (exit 2) fails the Job at once instead of retrying. Its pod takes none
of the web pods' podLabels, podAnnotations or affinity.
The web and workers pods keep their own ServiceAccount, so the role that may assume the
writer role is bound to the export's pod only. With IRSA, that role's trust names
system:serviceaccount:<namespace>:<fullname>-analytics-export. With roleArn set, its
only permission is sts:AssumeRole on roleArn; with roleArn empty it writes itself and
needs s3:PutObject on the bucket (and kms:GenerateDataKey on a customer-managed KMS
key). With EKS Pod Identity, bind the role to the ServiceAccount and leave annotations
empty.
The job reads ClickHouse with the release's Secret, settings, extraEnv and
extraVolumes, as the web pods do, and writes nothing to it. A failed run shows as a
failed Job; alert on the CronJob's last successful run being more than a day old.
A day whose run fails every attempt, or that misses its start window
(startingDeadlineSeconds, one hour), is not exported later on its own: the next run
exports only the day before it. Export it with a Job made from the CronJob, so it runs
under the export's ServiceAccount and flags, with --day added to its command:
kubectl create job --from=cronjob/<fullname>-analytics-export analytics-export-backfill \
--dry-run=client -o yaml > backfill.yaml
# add `- --day=YYYY-MM-DD` to the container's command in backfill.yaml, then:
kubectl apply -f backfill.yaml
The usage file is that day's; the namespace file is the snapshot at the time of the run.