mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
0a6dbec87c
## What
Swaps the `posthog-node` transport for an OpenTelemetry Logs exporter.
Everything above the transport stays as it is - opt-out consent,
per-machine identity and fingerprint, the sanitizer allowlist, the typed
event surface, crash diagnostics. Only the ~3-method client boundary
changes.
One analytics event becomes one OTLP/HTTP log record:
| PostHog | OpenTelemetry |
| --- | --- |
| `capture({ event, distinctId, properties })` | `logger.emit({ body:
event, attributes: { distinct_id, "event.name", ...props } })` |
| event name | log-record `body` |
| `distinctId` | `distinct_id` attribute |
| event/base properties | log-record attributes |
| `client.shutdown()` | `LoggerProvider.shutdown()` (force-flush +
teardown) |
Records go to a hard-coded Software Mansion collector. The endpoint and
the authorization header are passed to the exporter explicitly in code,
and an explicit value beats the corresponding `OTEL_EXPORTER_OTLP_*`
variable - the same anti-exfiltration property the fixed PostHog host
had.
## Changes
- `packages/telemetry/src/posthog.ts` becomes `otel.ts`:
`LoggerProvider` + `BatchLogRecordProcessor` + `OTLPLogExporter`
(HTTP/JSON). Queue size 20 and a 10 s flush interval mirror the old
client; the per-export deadline is 1.5 s, kept at or below `index.ts`'s
drain budget so a stalled export can't hold a short-lived command open.
- Dependencies: drop `posthog-node`, add
`@opentelemetry/{api,api-logs,sdk-logs,exporter-logs-otlp-http,resources}`.
They inline cleanly into the esbuild bundles.
- Build-time ingest token via esbuild `define`
(`ARGENT_OTEL_INGEST_TOKEN`), same mechanism as `ARGENT_CLI_VERSION`.
Unset in a dev build leaves the client unconstructed and telemetry
inert.
- Base props: drop `$process_person_profile` (a PostHog directive,
meaningless to a collector) and rename `$session_id` to `session_id`.
Null-valued attributes are dropped, since OTel rejects them -
`cloud_agent` is null on the common path, and absence means the same
thing.
- `Telemetry.md`: the "who we disclose to" and "international transfers"
sections describe a Software Mansion-operated OTLP collector instead of
PostHog as a third-party processor.
## A hang this uncovered
`timeoutMillis` only bounds a request once its socket is connected,
because the exporter applies it with `req.setTimeout()`. Against a
collector address that drops packets rather than refusing them - a
corporate egress filter, a dead host behind a firewall - the socket sits
in the connecting state where no export deadline reaches it, and the
process stays alive for the OS connect timeout: 75 s past a `shutdown()`
that had already resolved at 1.75 s.
Pairing the export deadline with an agent-level socket timeout, armed at
socket creation so it covers connection establishment too, fixes it:
| collector state | before | after |
| --- | --- | --- |
| healthy | 0.18 s, delivered | 0.18 s, delivered |
| connection refused | 0.94 s | 1.03 s |
| blackholed (http and https) | 75.0 s | 1.6 s |
| connected but never responds | - | 1.6 s |
`posthog-node` exited in 10.6 s under the same blackhole (undici aborts
a connecting socket), so this was a regression as well as a hang. A unit
test that fails without the fix guards it.
## Found in review
Each of these was reproduced before it was changed, and each is pinned
by a test that fails without the fix.
- **The environment's OTLP headers were forwarded to the collector.**
The SDK merges `OTEL_EXPORTER_OTLP_HEADERS` / `_LOGS_HEADERS` into every
request, keeping any key the code does not set itself. Out in the world
that variable holds the developer's *own* observability credential - a
Honeycomb team key, a Dynatrace `Api-Token`, Grafana Cloud basic auth -
so any machine already running OpenTelemetry shipped that third-party
secret here on every batch, without it passing the sanitizer or
appearing in `Telemetry.md`. Reproduced by watching `x-honeycomb-team:
hcaik_…` arrive at a capture server. The variables are now cleared
across the exporter constructor (where the SDK resolves its header set)
and restored after.
- **`httpAgentOptions` silently disabled connection reuse.** Supplying
it replaces the agent the SDK would build, whose default is `keepAlive:
true`. Measured on loopback: three batches shared one socket by default,
and opened three sockets with `httpAgentOptions` - a fresh TCP+TLS
handshake per 10 s batch in the long-lived tool-server. `keepAlive` is
restated alongside the timeout.
- **`markDisabled()`'s drain had no grace period.** It raced flat
against `SHORT_FLUSH_TIMEOUT_MS` while `shutdown()` allowed `timeoutMs +
250`. Since the exporter's own deadline equals that budget, the flat
race fired at the same instant and always abandoned the batch instead of
letting it finish. Both call sites share one `raceDrain()` now.
- **Export failures reached nobody.** The batch processor hands errors
to OpenTelemetry's global handler, so `LoggerProvider.shutdown()`
resolves identically for a delivered batch and a rejected token - making
`shutdown()`'s `catch` unreachable. The SDK's diagnostics are now routed
into the existing debug channel under `ARGENT_TELEMETRY_DEBUG=1`; a run
without the flag stays silent so no host application's diag logger is
disturbed.
- **The build-time token now beats the `__ARGENT_OTEL_TOKEN_TEST` seam**
rather than losing to it, so the seam reaches unbundled source only.
Verified with esbuild across all three build modes.
- **The endpoint guard test mocked the exporter it guards.** It could
only show that the code passes a `url`; an SDK upgrade that changed the
precedence would have kept it green. `otel-endpoint-live.test.ts` drives
the real exporter against loopback servers - dropping the explicit
`url`, the explicit `authorization`, or the header clearing each fails
it.
- **The wire shape had no pins at all.** Bypassing the sanitizer
entirely, sending an explicit `null`, renaming `service.name` and
dropping `event.name` all left the suite green. Four tests now fail on
those mutations. The sanitizer bypass was unpinned on `main` too.
## Before this ships
1. ~~**Collector URL**~~ - done in 8bca962f. `OTLP_LOGS_ENDPOINT` is
`https://argent-otel.swmansion.com/v1/logs`. That host serves OTLP as of
2026-08-12: a `POST` answers the OTLP-shaped `401`
(`{"code":16,"message":"provided authorization does not match expected
scheme or token"}`), where it previously `301`d to `/v1/logs/` and
`404`d there. It is a distinct host from the test collector
(`167.235.245.1` vs `46.224.172.171`, its own certificate).
2. **Ingest token** - `ARGENT_OTEL_INGEST_TOKEN` has to be set in the
release build environment. Nothing under `.github/` sets it today, so
every published artifact resolves an empty token and stays inert -
silently: with no token the client is never constructed, so a run emits
nothing and writes no `telemetry-debug.log` even under
`ARGENT_TELEMETRY_DEBUG=1`. It must be the **production** host's token:
`argent-otel.swmansion.com` runs its own token list and rejects the test
collector's.
3. **IP geolocation** - PostHog's `disableGeoip: true` set
`$geoip_disable` on every event, which is what stopped location being
derived from the source IP. There is no in-repo equivalent for a
collector, so that suppression has to be configured collector-side to
keep `Telemetry.md`'s "what we collect" list accurate.
4. **Legal** - the privacy-notice edits and the version/date bump to
1.03 are drafted to match the new architecture, but Legal should review
and finalize them. Note the notice actually served at
`TELEMETRY_DETAILS_URL` is still Version 1.01 and names PostHog as the
processor.
## Verification
Unit and integration:
- `packages/telemetry`: 293 tests pass, including the rewritten
`index.test.ts`, `otel-endpoint.test.ts` and the new
`otel-endpoint-live.test.ts`.
- Every workspace suite green on the merged branch: argent-cli 276,
argent-installer 535, argent-mcp 78, argent 60, tools-client 178,
configuration-core 83, registry 87, tool-server 3070, update-core 31,
archive 11.
- `bundle-tools.cjs` leaves no `posthog` in any of the four bundles.
OTel is inlined into the three that emit - `cli-cmds.mjs`,
`installer.mjs`, `tool-server.cjs` - each carrying the endpoint and the
token define as string literals. `mcp-server.mjs` carries none: it is a
stdio shim over the tool-server, which is the process that reports.
- `lint`, `prettier --check`, `tsc --build`, the `tsconfig.test.json`
gate and `npm install --package-lock-only` (idempotent on Node 24) all
clean.
142 lines
7.5 KiB
JSON
142 lines
7.5 KiB
JSON
{
|
|
// Anti-dead-code gate: unused files, exports and types nothing imports, and
|
|
// dependencies declared but never used (plus the inverse). Run it with
|
|
// `npm run knip`; .github/workflows/knip.yml enforces it.
|
|
//
|
|
// Every issue type knip reports is an error by default, so this file sets no
|
|
// `rules`. The pre-existing backlog is bounded instead: `npm run knip` runs
|
|
// knip twice, first over the classes that must be clean, then over the parked
|
|
// ones under `--max-issues`. That second pass names `classMembers`,
|
|
// `nsExports` and `nsTypes` explicitly because they sit in knip's own
|
|
// `defaultExcludedIssueTypes` — without that neither pass gates them, and a
|
|
// new unused class member costs nothing. Naming `classMembers` also turns
|
|
// knip's `isSkipLibs` off, which moves the export and type counts, so a run
|
|
// that names it is not comparable to one that does not.
|
|
//
|
|
// The ceiling is one total across those classes rather than one per class, so
|
|
// paying off a parked export licences a new one elsewhere in the same PR.
|
|
//
|
|
// Count it the way CI does, against a tree with no build output: whether a
|
|
// workspace resolves `main`/`types` to a file that exists moves the total by
|
|
// itself, so a run after a local `tsc --build` carries phantom headroom and is
|
|
// no evidence the job will pass.
|
|
"$schema": "https://unpkg.com/knip@5/schema.json",
|
|
|
|
// Makes a stale exemption here fail the job instead of printing a hint below
|
|
// the backlog — an `ignoreDependencies` name that suppresses nothing, an
|
|
// `entry` that matches nothing or is already reached, an `ignoreWorkspaces`
|
|
// path that does not exist. Mind that last one: if the submodule directory
|
|
// below goes missing the hint fires advising you to drop the entry, which is
|
|
// the one thing not to do. Restore the directory instead.
|
|
"treatConfigHintsAsErrors": true,
|
|
|
|
// Ships from its own repo with its own CI, and eslint and prettier skip it the
|
|
// same way. It becomes a workspace the moment `git submodule update --init`
|
|
// puts a package.json where the root `packages/*` glob matches it.
|
|
"ignoreWorkspaces": ["packages/argent-private"],
|
|
|
|
"workspaces": {
|
|
// Knip finds the root scripts on its own — via package.json `scripts`, the
|
|
// scripts/*.test.mjs files those run, and `run:` steps in .github/workflows.
|
|
// What its github-actions plugin cannot parse is a PowerShell body, so the
|
|
// Windows E2E driver, invoked from the windows-latest job where that is the
|
|
// default shell, is the one entry declared by hand.
|
|
//
|
|
// Knip analyzes JS and TS only, so the shell half of scripts/ — the whole
|
|
// scripts/e2e-full/ harness included — is outside the gate however it runs.
|
|
".": {
|
|
"entry": ["scripts/ci/windows-chromium-e2e.mjs"],
|
|
},
|
|
|
|
// @swmansion/argent is a thin dispatcher whose real payload is an esbuild
|
|
// bundle of the OTHER workspaces (see this workspace's
|
|
// scripts/bundle-tools.cjs); the hand-written dispatcher copied into bin/ at
|
|
// build time is an entry, not dead code.
|
|
//
|
|
// Knip cannot follow imports across that esbuild boundary, so a package
|
|
// consumed only by the bundled-in code reads as unused here. They are named
|
|
// one by one rather than waved through with a catch-all pattern:
|
|
// `ignoreDependencies` also suppresses `unlisted`, so a catch-all would take
|
|
// this workspace's own src/ and scripts/ out of that check — where an import
|
|
// with no matching declaration is a real bug, not a bundler artifact.
|
|
"packages/argent": {
|
|
"entry": ["scripts/argent-simulator-server.cjs"],
|
|
"ignoreDependencies": [
|
|
// dependencies
|
|
"@fails-components/webtransport",
|
|
// Not bundled-in code reaching it: @fails-components/webtransport
|
|
// imports this itself at runtime, so nothing first-party imports it.
|
|
"@fails-components/webtransport-transport-http3-quiche",
|
|
"@modelcontextprotocol/sdk",
|
|
"tree-sitter",
|
|
"tree-sitter-typescript",
|
|
// devDependencies
|
|
"@clack/prompts",
|
|
"@opentelemetry/api-logs",
|
|
"@opentelemetry/exporter-logs-otlp-http",
|
|
"@opentelemetry/resources",
|
|
"@opentelemetry/sdk-logs",
|
|
"picocolors",
|
|
"semver",
|
|
"smol-toml",
|
|
"yaml",
|
|
],
|
|
},
|
|
|
|
// tool-server reaches a few packages in ways knip can't trace statically.
|
|
// `ignoreDependencies` works in both directions, so this one list silences
|
|
// two different issue classes; they are kept apart below so each entry's
|
|
// stated reason matches the finding it actually suppresses. Each entry
|
|
// exempts the NAME across the whole workspace, not the call site it
|
|
// documents — while `sharp` is listed, an undeclared import of it from any
|
|
// file here passes too — so the list holds only packages this workspace
|
|
// genuinely cannot declare.
|
|
"packages/tool-server": {
|
|
"ignoreDependencies": [
|
|
// --- Declared in package.json, loaded too dynamically to detect.
|
|
// Without these: "unused dependencies". ---
|
|
// Dynamic `_require("tree-sitter")` / `_require("tree-sitter-typescript")`
|
|
// in react-profiler/pipeline/.../ast-index.ts, behind a one-shot flag.
|
|
"tree-sitter",
|
|
"tree-sitter-typescript",
|
|
// Optional peers of @swmansion/argent-cloud-sdk, which leaves them to
|
|
// the host to declare. utils/moq-client.ts calls the SDK's
|
|
// `installNodeWebTransport()`, but the dynamic import of the polyfill
|
|
// sits inside the SDK's own /node entry, so no first-party file names
|
|
// it; the transport is a second hop, imported by the polyfill itself.
|
|
"@fails-components/webtransport",
|
|
"@fails-components/webtransport-transport-http3-quiche",
|
|
|
|
// --- Imported here but deliberately absent from this package.json.
|
|
// Without these: "unlisted dependencies". ---
|
|
// Lazy `require("sharp")` in chromium-server/screenshot.ts. Kept out of
|
|
// the manifest on purpose so the ~30 MB native binary isn't a cost every
|
|
// consumer pays; the caller degrades gracefully when it isn't installed.
|
|
"sharp",
|
|
// `require("electron")` twice, for two different reasons. In
|
|
// utils/preview-window.ts it resolves the Electron executable's path
|
|
// from the install root, where it is an optionalDependency of the
|
|
// published @swmansion/argent. In test/fixtures/electron-smoke-app it
|
|
// is the Electron main-process API; that fixture declares electron in
|
|
// its own package.json and CI installs it there, but knip attributes
|
|
// the file to this workspace's manifest and never reads the nested one.
|
|
"electron",
|
|
],
|
|
},
|
|
},
|
|
|
|
// Blind spot in the `files` check: test files are entry points, so a
|
|
// production module that nothing but its own test imports still counts as
|
|
// reached — which in this repo is most of them. What `files` reliably catches
|
|
// is a module with no test at all. A file orphaned inside test/ is invisible
|
|
// for the same reason, knip's node plugin registering those trees as entries.
|
|
// `knip --production` is the lens that reports the whole class; the repo is
|
|
// not clean under it yet, so expect findings there that this gate does not
|
|
// raise.
|
|
//
|
|
// Reachability has a wider hole still: whatever a workspace's entry re-exports
|
|
// is reached, so one `export { x } from "./dead-module";` in a src/index.ts
|
|
// takes that module out of `files` altogether. The ceiling bounds what knip
|
|
// can see, not what is dead.
|
|
}
|