Repository navigation
Releases: agentstrack/collector
Release list
v0.5.0
v0.4.6
v0.4.5
v0.4.4
v0.4.3
v0.4.2
v0.4.1 — Redact before truncating, and report which secret pattern fired
This tag carries both 0.4.0 and 0.4.1; 0.4.0 has no separate commit in this repository and was never published to npm.
0.4.1
Fixed
-
A session title could ship a piece of a secret (security).
derived_titleis the first
meaningful line of your prompt, cut to 120 characters. Until now that cut was made on the raw
prompt and the secret scan ran afterwards, on the already-shortened title. If a key happened to
straddle the 120-character boundary, the cut split it in two, the leftover head no longer looked
like a key to any pattern, and it was uploaded as ordinary title text. A prompt of a hundred
characters followed by ansk-ant-…key produced a title ending insk-ant-api03-AAAAA…while
the event dutifully reportedsecrets_redacted: [{ anthropic_key, 1 }]— the tally was right and
the title still carried a fragment.The prompt is now redacted before the title is taken from it, in all three adapters (Claude
Code, Codex, OpenCode), so a truncation can only ever cut through a[REDACTED:…]marker. The
privacy pipeline still redactsderived_titleafterwards; that pass is now a no-op and stays in
place as defence in depth.The same cut-then-scan mistake applied to a Codex
errorevent'smessage, truncated to 1000
characters; it is redacted before truncation now too.What to do: only a partial value could escape, never a whole one, and only when a secret sat
across the cut. But a fragment is enough to identify which key was pasted, and the rest of it may
be guessable from context. If you have usedanalyticsorfullmode, look through your existing
session titles for key-shaped fragments, and rotate anything you find. Titles produced from 0.4.1
on are safe.Fragments already uploaded stay in the product until you delete those sessions — upgrading the
collector does not rewrite history.
Changed
- Documented the ordering (redact, then truncate) in the README and
docs/EVENT_SCHEMA.md, and
corrected two stale lines there that still saidprivacy.prompts: neverleavesderived_title
in place undermode: full. It has not since 0.4.0 —neverdrops it in every mode.
0.4.0
Added
- Secret exposure is reported as metadata. When local redaction fires, the event now carries
secrets_redacted: [{ kind, count }]— which pattern matched and how many times, sorted by kind
and omitted entirely when nothing fired. The matched value never travels: not the text, not a
prefix of it, not a hash, not the surrounding context. The tally is computed before the mode
strip, so it survivesmetadatamode — the mode where a team most wants to know a credential was
typed into an agent and least wants the credential itself. An org-supplied rule reports as the
single generic kindorg_rule, because a rule name can itself describe the shape of that
organization's secrets.
v0.3.0 — Skills, sub-agents, workflows and machine info
Added
- Skills, sub-agents and workflows are named. A Claude Code
tool_usenamedSkillcarries
skill;Agentcarriessubagent_typeanddescription;Workflowcarriesworkflow_name
(thenamefrom the script'smetaheader, when it is that simple). TheAgentcall's prompt and the
script body are never copied out of the call; a sub-agent transcript's own opening prompt is a
prompt like any other and followsprivacy.prompts. The extras ride ontool.startedand on the matching
tool.completed/tool.failed. - Sub-agent transcripts are attributed. Claude Code writes each sub-agent to
<session-uuid>/subagents/[workflows/<wf>/]agent-<id>.jsonlwith the parent's session id; the
tailer already walked them, but nothing said which lines were the sub-agent's. Every event from
such a file (or any line withisSidechain: true) now carriessidechain: true,agent_id,
agent_kind(subagent|workflow) and, from the siblingagent-<id>.meta.json,agent_type
— so the sub-agent's ownmodel.responseusage can be attributed to it server-side. user.prompted.ultracode,truewhen the prompt contains the whole wordultracode
(case-insensitive). Computed locally before redaction, so it survivesmetadatamode; the prompt
does not travel to be inspected.- Machine info on register and health. Alongside
hostname,osandarchthe collector now
sendsos_releaseandmachine_kind—ci(a CI env var),container
(/.dockerenvor a docker/containerd/kubepods cgroup),workstation(macOS, Windows, or Linux
with a display),server(headless Linux), elseunknown. Health repeats it, so a device that
changes shape is updated. See the README privacy section for what the server does with it.
Changed
description(the Agent tool's one-line label) is treated like a title: secret-redacted, and
dropped inmetadatamode.
Security
api_urlmust behttps. The bearer API key rides on every request, sohttpis now rejected
for any host exceptlocalhost/127.0.0.1/[::1].loginprints the URL it is about to use.- The API key can stay off the command line.
agentstrack loginnow takes the key as an optional
argument and otherwise readsAGENTSTRACK_API_KEY, an echo-off terminal prompt, or stdin
(agentstrack login < key.txt), keeping it out of shell history andps. config.yamlis written atomically at mode 600 — a temp file created 0600 then renamed over the
target, so a crash can no longer leave a truncated config or a brief world-readable window.- Org redaction rules are bounded. A server-supplied pattern that is malformed, over 256
characters, or using a backreference is skipped (as a malformed rule already was), the common
catastrophic nested-quantifier shapes ((a+)+,(a|aa)+,((a+)b)+) are rejected, and org rules
match only the first 64 KB of a value — a pathological pattern is much less likely to hang the
single-threaded daemon.
Fixed
- Claude Code tokens and cost were inflated ~1.8x. Claude Code writes one
assistantline per
content block of a single response, each repeating the samemessage.idand usage; every line
became amodel.response. Usage is now emitted once permessage.id. - Claude Code tool outcomes were all named
unknown.tool_resultblocks carry only a
tool_use_id; the name is now resolved from thetool_usethat started the call. - Claude Code prompts pasted with an image (or as text blocks) were never counted, and slash
command echoes (<command-name>,<command-message>,<local-command-stdout>,
<task-notification>) were counted as human prompts. Both fixed. - Codex sessions went dark after a collector restart. The session id lived only in memory from
session_meta; it is now seeded from the rollout file name, which carries the same uuid. - Codex cached tokens were billed twice.
cached_input_tokensis a subset ofinput_tokens
in Codex; the collector now subtracts it so the two are exclusive, as the schema documents. - Codex shell commands landed as
shellwithduration_ms: 0and never failed.
exec_command_endcarries an argv list, a{secs,nanos}duration and a numeric exit code;
function_call_output.outputis a string. Both are read as they really are, andtool.failed
is emitted on a non-zero exit. - OpenCode
session.endedwas rejected by the server on every emit (missing
external_session_id,reasonoutside the enum). It now sendsreason: normalwith the
archived/compacted distinction inend_kind. - OpenCode resumed sessions never got a
session.started. Sessions are fetched by
time_updatedbut starts were gated on atime_createdcursor; a per-session marker in the
spool's meta store replaces it. logoutnow tears the service down first. It previously left the launchd/systemd unit
installed, so the supervisor kept restarting an unauthenticated collector. Both units now restart
only on a crash (launchdKeepAlive/SuccessfulExit, systemdRestart=on-failurewith a
5-in-5-minutes start limit), and the unauthenticated foreground path exits cleanly so the
supervisor idles.- Service units handle paths with spaces and non-ASCII characters. The CLI path is resolved with
fileURLToPathinstead of a percent-encodedURL.pathname, plist strings are XML-escaped, and
systemdExecStartarguments are quoted.AGENTSTRACK_HOMEis written into the unit when set. - A second foreground
startrefuses to run when one is already collecting (checked via the pid
file, created withwx), preventing two collectors from racing on one spool. stopverifies the pid still belongs to a collector (viaps) before signalling it, so a
recycled pid in a stale pid file is not killed.doctorreports the real scan window. It now printsmodified in the last N day(s)using
tracking.max_age_daysinstead of a hard-coded "7 days".git.commitpolling stops re-diffing history every tick. Each repo'sgit log --sincenow
starts from its last poll, SHAs are listed before any diffstat sogit show --numstatruns only for
commits not yet emitted, and the emitted-SHA guard is pruned by age instead of cleared wholesale, so
a commit inside the lookback window is never re-emitted.
Changed
detect()reads only the first 16 KB of the newest transcript (by mtime) for the agent version,
cached on mtime, instead of the whole file every 5 s. OpenCode keeps one read-only database
handle with prepared statements rather than opening and closing one per query, and caches the
version for 60 s.- The contract test now runs every adapter fixture through the server's own payload schemas when
the server checkout is present, and fails (rather than skips) whenAGENTSTRACK_SERVER_REPOis
set but missing. - Node floor is
>=22(was>=24), matching.nvmrcand the runtime the code actually needs;
CI now tests Node 22 and 24. statusshows an upload-paused reason when one is present.
Fixed — daemon, queue and transport
- The upload failure policy ran once per concurrent batch, not once per wave. With
upload.concurrency: 4a dead API escalated the backoff counter by four per wave (5-minute waits
after two waves), slept inside the wave so tailing froze for the duration, and a sibling's success
reset the counter or undid a413halving.sendBatchnow returns a pure outcome andflush()
applies the policy once on the wave: halve once, one backoff step, strikes only for poison
batches, counter reset only when the whole wave succeeded. Backoff sets a next-upload time instead
of sleeping, honoursRetry-After/Retry-After-ingestas the minimum, and a daemon tick sends
at most five waves before scanning again. Covered bysrc/queue/flush.test.ts. 401/403no longer count strikes against telemetry. They pause uploads (statusshows the
reason), as does an over-quota200— previously acked and dropped locally with thequotablock
ignored — and a200that rejects every event as malformed, which now logs a version-mismatch
error instead of deleting the batch.- Checkpoints were written before the events were spooled. A throw between the two lost those
lines for good. The tailer now streams in 4 MB chunks and commits each chunk's checkpoint in the
same transaction as its events; a line over 8 MB is skipped to the next newline and counted, at
most 64 MB per file is read per scan, short reads are looped, and one unreadable file no longer
aborts the scan for every file after it. - Re-reading a transcript double-counted on the server.
event_idwas a freshrandomUUIDper
spool write; it is now derived from the adapter, file, byte offset and line content (a UUID v8
shape), so a rotated inode, a purged spool or a second collector on the same files dedupes. VERSIONwas hard-coded0.1.0. It is read frompackage.json; the CLI, register and health
report the published version, and every request carriesUser-Agent: agentstrack-collector/<v>.- A Claude Code response spanning a restart was billed twice.
model.responseis now keyed on
message.id(the daemon's deterministic id, seeded by the adapter), not on the line, so the
server's dedupe absorbs the second line's copy after a restart. - One rejected event paused every upload as a "schema mismatch". A single-event batch the
server rejects is now struck like any other poison event; the pause is reserved for a whole
batch rejected without a quota reason. - A failed commit left the checkpoint cache ahead of disk.
setCheckpoint()updated the
in-memory Map before COMMIT; the cache is now reloaded from the table when a transaction throws. - OpenCode cursors and started-markers were written before the events they covered. They are
now buffe...
v0.2.1 — Parallel uploads, the OpenCode adapter, and a full history import
Published from a squashed history: this tag carries both 0.2.0 and 0.2.1, which have no separate commit in this repository. Both sets of notes are below.
0.2.1
Fixed
-
A first import silently stopped at 7 days.
listTranscriptsdefaulted tomaxAgeDays = 7and
both call sites took the default, so only transcripts touched in the last week were ever opened —
258 of 690 files on the machine this was found on. Everything older stayed on disk with nothing in
the output indicating it had been skipped. The window is nowtracking.max_age_days, still
defaulting to 7 so a running collector keeps its cheap steady-state scan and a fresh install does
not unexpectedly upload years of history. Set it to3650, restart, then set it back to import
what is already on disk. -
OpenCode ignored that window and kept its own. It reads a live SQLite database rather than
tailing files, so it has an independent history floor — also hardcoded to 7 days. Widening
max_age_daystherefore backfilled Claude Code and Codex completely and left OpenCode at 4 of 19
sessions. The symptom was a lopsided event mix: 19user.promptedbut only 4session.started,
because the part cursor and the session-created cursor fell back to that floor differently. The
adapter now takes the same setting.Together these two are why a full import of one machine went from 63 sessions to 196.
Added
tracking.max_age_days(default 7, max 3650) — see above.
0.2.0
Added
-
Parallel uploads.
upload.concurrency(default 4, max 8) sends that many batches at once.
Uploading is round-trip bound rather than bandwidth bound. Measured on an 80k-event backfill:
upload throughput ~330 -> ~627 events/s, wall clock 240s -> 175s. The end-to-end gain is smaller
than the upload gain because reading and parsing transcripts is single-threaded and becomes the
co-bottleneck once uploading stops being one.Arrival order is deliberately not preserved across in-flight batches and does not need to be: the
server derives a session's start frommin(recorded start, earliest stored event)and re-runs
reconstruction after every batch, so a later batch landing first is corrected once the rest arrive.
Onepeekcovers the whole wave — peeking per batch would hand identical rows to every request and
upload the same eventsconcurrencytimes. A failing batch no longer abandons its siblings; the
413 / poison-batch / backoff policy is unchanged.Set
upload.concurrency: 1in~/.agentstrack/config.yamlto restore serial uploads. -
OpenCode adapter. Reads
~/.local/share/opencode/opencode.db(honouring$XDG_DATA_HOME,
$OPENCODE_DATA_DIRand$OPENCODE_DB) and emitssession.started,session.ended, cumulative
usage.reported,user.prompted,tool.completed/tool.failed,command.executed,
file.changedandfile.read. OpenCode records the provider's real settled cost, so it is the
first agent whose sessions carrycost_basis: REPORTEDrather than an estimate.This is a live database the OpenCode app writes to, so it is opened
readonly+fileMustExist
withPRAGMA query_onlyand abusy_timeout, one short query at a time. The collector never
writes to, migrates or locks a user's data. -
AgentAdapter.poll(), an optional pull-based source for database-backed agents.normalize()
is line-oriented and a SQLite file has no lines; rather than pretend otherwise, the daemon drives
poll()on the same scan cycle, under the sametracking.agentsgating, the same
excluded_projectsfilter and the same privacy pipeline. Resumption uses the spool's meta store —
the tailer's(path, inode, offset)checkpoint means nothing to a database. -
Per-account attribution.
session.startedanduser.promptednow carry an optionalaccount
object: a stable opaquekey(Claude Code'soauthAccount.accountUuid; OpenCode's
<serviceID>:<accountId>), pluslabel,organdprovider. Sessions from a personal login and
a work login no longer merge into one identity and one bill.labelandorgare PII and are stripped by the privacy pipeline inmetadatamode;keyand
provideralways travel, so sessions still split correctly there. Credentials are never read:
OpenCode'saccount.jsonholds a live API key beside the account id and onlyid/serviceIDare
touched, whileauth.jsonis never opened.Events that predate the collector's start carry no account. These files record only who is
signed in now, so a historical transcript cannot be attributed without guessing, and a guess that
looks like a fact is worse than a blank. -
SUPPORT.md,ROADMAP.md,GOVERNANCE.md,CODEOWNERS,.editorconfig,.gitattributes,
.nvmrc, Dependabot config and a CodeQL workflow.
Fixed
session.started.cwdis no longer sent as an absolute path regardless of
privacy.file_paths.cwdis the project root, and it leaked the username, the client name and
the directory tree even inmetadatamode — whererepo.project_pathwas already being dropped.
It now follows the same rule: sent only underfile_paths: absolute. Affected the Codex adapter
since 0.1.0.
Changed
tracking.agentsdefaults to[claude_code, codex, opencode].privacy.prompts: nevernow strips the locally derivedderived_titleinanalyticsmode, so
it is possible to keep token, tool and cost analytics while sending nothing derived from a prompt.
Previously the setting was consulted only infullmode, which madeneverand
local_summary_onlybehave identically everywhere else — a privacy control that silently did
nothing.nevernow applies in every mode,fullincluded.- Agent versions in the registration payload are real.
ClaudeCodeAdapter.detect()reads the
versionfield out of a transcript (e.g.2.1.247) instead of reporting the literal string
'detected'. An adapter that cannot cheaply establish a version reports none, rather than a
placeholder — which today is the case for Codex, whosedetect()does not open a rollout to read
session_meta.cli_version. ~/.agentstrack/is created mode700, andspool.dbwith its-wal/-shmsidecars is
chmoded600. Previously onlyconfig.yamlwas hardened, so on a shared host another user could
read spooled telemetry.- The schema contract test resolves the server repository from a sibling
../agentstrack.aicheckout
orAGENTSTRACK_SERVER_REPO, instead of an absolute path to one maintainer's machine. It still
skips cleanly when the server repo is absent.
Fixed
- A partial trailing line is no longer consumed. The tailer advances its checkpoint only to the
last complete newline and re-reads an unterminated line whole on the next pass. Previously a line
the agent was mid-way through writing was emitted broken and its remainder arrived orphaned, so
both halves failed to parse and the event was lost — which happened on every live session. - A failed batch can no longer delete unrelated queued events.
Spool.fail()now scopes its
retry-exhaustion delete to the event ids in the failing batch; it previously deleted table-wide on
attempts, so one batch exhausting its retries could destroy events it had never touched. upload.max_retries: 0no longer empties the spool. The value is clamped to a minimum of 1.
attempts >= 0matched every row, including never-attempted ones, so a config of0wiped the
whole queue on the first upload failure. The config still accepts0–20;0behaves as1.