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.
324 lines
13 KiB
TypeScript
324 lines
13 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import express from "express";
|
|
import request from "supertest";
|
|
import type { Registry } from "@argent/registry";
|
|
|
|
// Capture telemetry without touching the transport: keep every real export (other
|
|
// modules in the router's import graph read them at load) and spy only `track`.
|
|
vi.mock("@argent/telemetry", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("@argent/telemetry")>();
|
|
return { ...actual, track: vi.fn() };
|
|
});
|
|
|
|
import { createPreviewRouter } from "../src/preview";
|
|
import { variantProposalStore } from "../src/utils/variant-proposals";
|
|
import { track } from "@argent/telemetry";
|
|
|
|
const mockTrack = vi.mocked(track);
|
|
|
|
function app() {
|
|
const registry = { invokeTool: vi.fn() } as unknown as Registry;
|
|
const a = express();
|
|
// Mirror the real server (http.ts mounts express.json app-wide before the
|
|
// preview router) so POST bodies to /variants/selection are parsed. Harmless
|
|
// for the GET cases (no body).
|
|
a.use(express.json());
|
|
a.use(createPreviewRouter(registry));
|
|
return a;
|
|
}
|
|
|
|
const previewOpenedCalls = () =>
|
|
mockTrack.mock.calls.filter(([event]) => event === "lens:preview_opened");
|
|
|
|
beforeEach(() => {
|
|
mockTrack.mockClear();
|
|
// The store is a process singleton; clear any round state a prior test left so
|
|
// each case starts from an empty, uncompleted round. `reset()` deliberately
|
|
// does NOT clear `cliSession`, so drop a leaked CLI session too — otherwise a
|
|
// prior CLI test could make a "bare load, no CLI" case emit via the cliSession
|
|
// disjunct under --sequence.shuffle. Neither call routes through `track`, so
|
|
// the mockClear above stays effective.
|
|
variantProposalStore.setCliSession(false, []);
|
|
variantProposalStore.reset();
|
|
});
|
|
|
|
describe("POST /preview/opened — lens:preview_opened telemetry", () => {
|
|
// `lens:preview_opened` is emitted only from an explicit client trigger — the
|
|
// UI posts `/opened` when it renders a round in a VISIBLE window. The server
|
|
// reads counts/platform from its own snapshot (the body's `round` is a trigger
|
|
// only), and dedups per round. These cases pin the server side of that signal;
|
|
// the visibility gate itself is client-side (see index.html reportPreviewOpened).
|
|
|
|
it("emits once when the client reports a live proposal round", async () => {
|
|
const a = app();
|
|
variantProposalStore.proposeVariant({
|
|
element: "Foo",
|
|
variant: { name: "Bold", summary: "s" },
|
|
});
|
|
const round = variantProposalStore.snapshot().round;
|
|
|
|
const res = await request(a).post("/opened").send({ round });
|
|
|
|
expect(res.status).toBe(200);
|
|
const calls = previewOpenedCalls();
|
|
expect(calls).toHaveLength(1);
|
|
const payload = calls[0]![1] as Record<string, unknown>;
|
|
// No udid bound here, but the store `device` is a process singleton NOT cleared
|
|
// by reset(), so `platform` can carry over from an earlier test under
|
|
// --sequence.shuffle. Assert the controlled fields, pin the exact key set, and
|
|
// check platform is a safe enum or undefined — never leaked content.
|
|
expect(payload).toMatchObject({
|
|
round,
|
|
element_count: 1,
|
|
variant_count: 1,
|
|
is_cli_session: false,
|
|
});
|
|
expect(Object.keys(payload).sort()).toEqual([
|
|
"element_count",
|
|
"is_cli_session",
|
|
"platform",
|
|
"round",
|
|
"variant_count",
|
|
]);
|
|
expect(payload.platform === undefined || typeof payload.platform === "string").toBe(true);
|
|
});
|
|
|
|
it("reports variant_count and platform from the bound device", async () => {
|
|
const a = app();
|
|
variantProposalStore.proposeVariant({
|
|
element: "Foo",
|
|
udid: "chromium-cdp-9222",
|
|
variant: { name: "Bold", summary: "s" },
|
|
});
|
|
variantProposalStore.proposeVariant({
|
|
element: "Foo",
|
|
variant: { name: "Ghost", summary: "s" },
|
|
});
|
|
const round = variantProposalStore.snapshot().round;
|
|
|
|
await request(a).post("/opened").send({ round });
|
|
|
|
const calls = previewOpenedCalls();
|
|
expect(calls).toHaveLength(1);
|
|
expect(calls[0]![1]).toEqual({
|
|
round,
|
|
element_count: 1,
|
|
variant_count: 2,
|
|
is_cli_session: false,
|
|
platform: "chromium",
|
|
});
|
|
});
|
|
|
|
it("dedups within the same round (repeated triggers count once)", async () => {
|
|
const a = app();
|
|
variantProposalStore.proposeVariant({
|
|
element: "Foo",
|
|
variant: { name: "Bold", summary: "s" },
|
|
});
|
|
const round = variantProposalStore.snapshot().round;
|
|
|
|
await request(a).post("/opened").send({ round });
|
|
await request(a).post("/opened").send({ round }); // e.g. a second tab, same round
|
|
|
|
expect(previewOpenedCalls()).toHaveLength(1);
|
|
});
|
|
|
|
it("re-emits on a NEW round (dedup is per-round, not once-ever)", async () => {
|
|
const a = app();
|
|
variantProposalStore.proposeVariant({
|
|
element: "Foo",
|
|
variant: { name: "Bold", summary: "s" },
|
|
});
|
|
const roundN = variantProposalStore.snapshot().round;
|
|
await request(a).post("/opened").send({ round: roundN });
|
|
expect(previewOpenedCalls()).toHaveLength(1);
|
|
|
|
// Finalize round N; the next propose auto-rolls into a fresh round N+1.
|
|
const foo = variantProposalStore.snapshot().proposals[0]!;
|
|
variantProposalStore.submitSelection({
|
|
selections: [{ elementId: foo.id, variantId: foo.variants[0]!.id }],
|
|
});
|
|
variantProposalStore.proposeVariant({
|
|
element: "Bar",
|
|
variant: { name: "Ghost", summary: "s" },
|
|
});
|
|
const roundNext = variantProposalStore.snapshot().round;
|
|
expect(roundNext).toBe(roundN + 1);
|
|
|
|
// The client rendering the NEW round reports it — a second event. A regression
|
|
// to a fire-once guard would wrongly suppress this and leave the count at 1.
|
|
await request(a).post("/opened").send({ round: roundNext });
|
|
const calls = previewOpenedCalls();
|
|
expect(calls).toHaveLength(2);
|
|
expect(calls[1]![1]).toMatchObject({
|
|
round: roundNext,
|
|
element_count: 1,
|
|
variant_count: 1,
|
|
is_cli_session: false,
|
|
});
|
|
});
|
|
|
|
it("does NOT emit when nothing is staged and no CLI session (a stray trigger)", async () => {
|
|
const a = app();
|
|
const res = await request(a).post("/opened").send({ round: 1 });
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(previewOpenedCalls()).toHaveLength(0);
|
|
});
|
|
|
|
it("emits for a CLI up-front open with zero proposals, OMITTING the (stale) platform", async () => {
|
|
const a = app();
|
|
// Bind a device via an earlier round, then roll it away: `device` survives
|
|
// reset() on the store singleton, so it is still set with zero proposals.
|
|
variantProposalStore.proposeVariant({
|
|
element: "Foo",
|
|
udid: "chromium-cdp-9222",
|
|
variant: { name: "Bold", summary: "s" },
|
|
});
|
|
variantProposalStore.reset();
|
|
// An `argent lens` session opens the window up front — zero proposals staged.
|
|
variantProposalStore.setCliSession(true, []);
|
|
const round = variantProposalStore.snapshot().round;
|
|
expect(variantProposalStore.snapshot().device).toBe("chromium-cdp-9222"); // still bound
|
|
|
|
await request(a).post("/opened").send({ round });
|
|
|
|
const calls = previewOpenedCalls();
|
|
expect(calls).toHaveLength(1);
|
|
// element_count 0 (CLI up-front) AND platform OMITTED: a zero-count open must
|
|
// not inherit a prior flow's device platform (the store's `device` survives
|
|
// reset()). This pins the "platform tied to proposals presence" fix.
|
|
expect(calls[0]![1]).toEqual({
|
|
round,
|
|
element_count: 0,
|
|
variant_count: 0,
|
|
is_cli_session: true,
|
|
});
|
|
|
|
variantProposalStore.setCliSession(false, []); // don't leak session state
|
|
});
|
|
});
|
|
|
|
describe("GET /preview/ and /preview/variants — no longer emit preview_opened", () => {
|
|
// The page load and the poll are NOT proof a human is looking (a reused CLI
|
|
// window loads `/` once for a whole session; a browser tab keeps polling
|
|
// /variants while backgrounded). Emission moved to the visibility-gated client
|
|
// trigger, so these surfaces must stay silent — a regression guard.
|
|
|
|
it("GET / does not emit even with a live round", async () => {
|
|
const a = app();
|
|
variantProposalStore.proposeVariant({
|
|
element: "Foo",
|
|
variant: { name: "Bold", summary: "s" },
|
|
});
|
|
const res = await request(a).get("/");
|
|
expect(res.status).toBe(200);
|
|
expect(previewOpenedCalls()).toHaveLength(0);
|
|
});
|
|
|
|
it("GET /variants does not emit even with a live round", async () => {
|
|
const a = app();
|
|
variantProposalStore.proposeVariant({
|
|
element: "Foo",
|
|
variant: { name: "Bold", summary: "s" },
|
|
});
|
|
const res = await request(a).get("/variants");
|
|
expect(res.status).toBe(200);
|
|
expect(previewOpenedCalls()).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe("POST /preview/variants/selection — inspector/offscreen usage flags", () => {
|
|
it("threads the UI's inspectorUsed / offscreenRevealed booleans to lens:round_completed", async () => {
|
|
const a = app();
|
|
variantProposalStore.proposeVariant({
|
|
element: "Foo",
|
|
variant: { name: "Bold", summary: "s" },
|
|
});
|
|
const foo = variantProposalStore.snapshot().proposals[0]!;
|
|
// roundCompleted is the store event index.ts relays to lens:round_completed;
|
|
// capture it to prove the POST body's flags reach the aggregate (not `track`,
|
|
// which the router only calls for lens:preview_opened).
|
|
const stats: Array<{ inspector_used: boolean; offscreen_revealed: boolean }> = [];
|
|
const onCompleted = (x: { inspector_used: boolean; offscreen_revealed: boolean }) =>
|
|
stats.push(x);
|
|
variantProposalStore.events.on("roundCompleted", onCompleted);
|
|
|
|
const res = await request(a)
|
|
.post("/variants/selection")
|
|
.send({
|
|
selections: [{ elementId: foo.id, variantId: foo.variants[0]!.id }],
|
|
inspectorUsed: true,
|
|
offscreenRevealed: true,
|
|
});
|
|
|
|
variantProposalStore.events.off("roundCompleted", onCompleted);
|
|
expect(res.status).toBe(200);
|
|
expect(stats).toHaveLength(1);
|
|
expect(stats[0]!.inspector_used).toBe(true);
|
|
expect(stats[0]!.offscreen_revealed).toBe(true);
|
|
});
|
|
|
|
it("coerces a missing/non-boolean flag to false (unauthenticated route)", async () => {
|
|
const a = app();
|
|
variantProposalStore.proposeVariant({
|
|
element: "Foo",
|
|
variant: { name: "Bold", summary: "s" },
|
|
});
|
|
const foo = variantProposalStore.snapshot().proposals[0]!;
|
|
const stats: Array<{ inspector_used: boolean; offscreen_revealed: boolean }> = [];
|
|
const onCompleted = (x: { inspector_used: boolean; offscreen_revealed: boolean }) =>
|
|
stats.push(x);
|
|
variantProposalStore.events.on("roundCompleted", onCompleted);
|
|
|
|
// inspectorUsed sent as a non-boolean, offscreenRevealed omitted entirely.
|
|
const res = await request(a)
|
|
.post("/variants/selection")
|
|
.send({
|
|
selections: [{ elementId: foo.id, variantId: foo.variants[0]!.id }],
|
|
inspectorUsed: "yes",
|
|
});
|
|
|
|
variantProposalStore.events.off("roundCompleted", onCompleted);
|
|
expect(res.status).toBe(200);
|
|
expect(stats).toHaveLength(1);
|
|
expect(stats[0]!.inspector_used).toBe(false);
|
|
expect(stats[0]!.offscreen_revealed).toBe(false);
|
|
});
|
|
|
|
it("forwards the round token so a stale cross-round submit is rejected (no phantom completion)", async () => {
|
|
const a = app();
|
|
// Round N: propose + submit (matching token) → completes round N. (The store
|
|
// is a process singleton; round numbers accumulate across tests, so capture
|
|
// them dynamically rather than assuming 1/2.)
|
|
variantProposalStore.proposeVariant({ element: "Foo", variant: { name: "A", summary: "s" } });
|
|
const foo = variantProposalStore.snapshot().proposals[0]!;
|
|
const roundN = variantProposalStore.snapshot().round;
|
|
await request(a)
|
|
.post("/variants/selection")
|
|
.send({ round: roundN, selections: [{ elementId: foo.id, variantId: foo.variants[0]!.id }] });
|
|
|
|
// Agent proposes again → auto-roll to round N+1.
|
|
variantProposalStore.proposeVariant({ element: "Bar", variant: { name: "B", summary: "s" } });
|
|
expect(variantProposalStore.snapshot().round).toBe(roundN + 1);
|
|
|
|
const completed: number[] = [];
|
|
const onCompleted = (x: { round: number }) => completed.push(x.round);
|
|
variantProposalStore.events.on("roundCompleted", onCompleted);
|
|
|
|
// A stale click from the round-N tab: round token N, current round is N+1. The
|
|
// route must forward the token so the store rejects it (regression guard —
|
|
// the route previously dropped `round`, letting the phantom through).
|
|
const res = await request(a)
|
|
.post("/variants/selection")
|
|
.send({ round: roundN, selections: [{ elementId: foo.id, variantId: foo.variants[0]!.id }] });
|
|
|
|
variantProposalStore.events.off("roundCompleted", onCompleted);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.stale).toBe(true);
|
|
expect(res.body.resolved).toBe(0);
|
|
expect(completed).toEqual([]); // no phantom lens:round_completed for round 2
|
|
expect(variantProposalStore.snapshot().completed).toBe(false);
|
|
});
|
|
});
|