Files
software-mansion__argent/packages/telemetry/test/index.test.ts
Ignacy Łątka 16ad44bb0e test(telemetry): pin the wire format and what a dead collector costs (#736)
Two test files that look at what #570's exporter actually *does*, plus
the configuration holes they exposed.

The suite on #570 proves the exporter is **configured** right - the url,
the headers, the timeouts, all read off the constructor. Nothing looked
at the request that comes out of it, or at what happens when the
collector does not answer. Both gaps are invisible from inside Argent by
design: a failed export never reaches the caller, and an OTLP exporter
counts any 2xx as delivered.

| | |
|---|---|
| **`otel-wire.test.ts`** | Captures the real exporter's request off a
loopback socket: uncompressed OTLP/JSON on `POST /v1/logs`, where each
field the ingestion schema reads actually sits, the batch-size cap, and
connection reuse. |
| **`otel-unreachable.test.ts`** | Drives the same exporter at a silent
socket, a refused port, a 503, a 401 and a 204, and asserts each drain
finishes inside its deadline and what the collector really received. |
| **`src/otel.ts`** | Two holes the tests exposed: `compression` and the
six OTLP certificate variables are now settled in code instead of left
to the environment. |

**No docs update needed** - no user-facing capability, CLI surface,
config key, flow file or MCP tool changed.

<details>
<summary><b>The two source fixes</b></summary>

**`compression` was the one OTLP knob still left to the environment.**
With `OTEL_EXPORTER_OTLP_COMPRESSION=gzip` (or its `_LOGS_` variant) set
- and a machine already running OpenTelemetry has it set for its own
collector - argent gzips its batches. Nothing downstream notices: a
gzipped 2xx is a delivered batch, while the request this suite and the
deploy-time smoke test are written against quietly stops being the
request argent sends. `createExporter` now passes `compression`
explicitly, which beats both variables. Unlike the header channel, where
the SDK *merges* whatever the code does not set and the variables have
to be cleared outright, one explicit value settles this.

**The certificate variables could hang the command outright.** The SDK
resolves
`OTEL_EXPORTER_OTLP_{,LOGS_}{CERTIFICATE,CLIENT_CERTIFICATE,CLIENT_KEY}`
with a synchronous `fs.readFileSync` while the exporter is constructed,
and only then discards the https agent it built from them in favour of
the explicit `httpAgentOptions`. The file is read for nothing - and a
path that never answers hangs the command that emitted the event,
uninterruptibly, on `getClient()`'s path. Measured with a fifo that has
no writer: `createExporter` never returned (killed at 12s); with the
variables cleared, 1.0 ms. They join the header pair in the list cleared
across the constructor and restored on the way out.

Some source prose was also wrong about the SDK and is corrected: it does
**not** reject a null attribute - it serializes one as an empty OTLP
value, `{"key":"cloud_agent","value":{}}` with `droppedAttributesCount:
0`, stored as and unrecoverable from a property that really was empty.
That is the actual reason `toAttributes` drops the key.

</details>

<details>
<summary><b>What the tests pin, and what they deliberately do
not</b></summary>

The wire test **mirrors** `OtelClient.emit` rather than calling it:
`getClient()` resolves its endpoint from the hard-coded
`OTLP_LOGS_ENDPOINT`, and that being unredirectable is the
anti-exfiltration property the client is meant to have, so there is
deliberately no seam to aim it at a test server. `otel-endpoint.test.ts`
covers the other half against the real emit path - `SERVICE_NAME`, the
logger name, event-name-as-body, the dropping of null/undefined
properties - so a rename is caught there rather than in the mirror.

What enforces the drain budgets is the exporter's `timeoutMillis`, *not*
the processor's `exportTimeoutMillis`, which `forceFlush()` awaits
straight past. Measured against a silent socket: `exportTimeout` 200 /
`timeoutMillis` 5000 drains in 5012 ms, the reverse in 202 ms.

503 is the one collector state where the batch is sent twice, and the
request count is the only thing that tells a retried failure from a
rejected one - the SDK reports the class, not the code. The retry itself
is only reachable while the next backoff fits in what is left of
`timeoutMillis`, so the jitter is pinned to its low draw and the
assertion turns on the round trip alone.

`httpAgentOptions.timeout` is the one option with no behavioural
coverage: it covers a socket stuck in *connect*, which needs an address
whose packets are dropped and is not portably reproducible here. It
stays pinned at the constructor and the docstring says so. Its
`keepAlive` half **is** covered, on the two batches of a single drain.

Real deadlines cost real time: `otel-unreachable.test.ts` waits ~5.1 s,
taking the package suite from ~0.9 s to ~5.3 s. Inherent to driving real
deadlines against real sockets.

</details>

<details>
<summary><b>Verification</b></summary>

- `npm test -w @argent/telemetry` - **336 passed**, 19 files
- `typecheck:tests`, `eslint --max-warnings 0`, `prettier --check`,
`knip`, `tsc --build` all clean; CI green
- **Mutation-tested**, whole-suite counts rather than one test each:

  | mutation in `src/otel.ts` | tests red |
  |---|---|
  | `compression` pin dropped | 2 |
  | `compression` pinned to `"gzip"` | 5 |
  | any one of the 8 cleared env-var names dropped | 1 each |
  | env-clearing loop deleted | 2 |
  | the `finally` that restores it deleted | 1 |
  | `timeoutMillis` dropped | 5 |
  | `httpAgentOptions` dropped, or just its `timeout` | 1 |
  | `keepAlive` flipped to `false` | 2 |
  | `installDiagLogger`'s `!isDebugEnabled()` guard removed | 1 |
| `maxExportBatchSize` 20 → 19 or 25; `scheduledDelayMillis` changed | 2
|
  | `SERVICE_NAME` renamed | 2 |
  | `LOGGER_NAME` changed | 1 |
  | record body no longer the event name | 4 |
  | `severityText` INFO → WARN | 1 |
  | null/undefined property skip removed | 2 |
  | `url` given a suffix the config did not set | 9 |

- **Absence assertions carry positive controls.** The certificate test
reads its answer out of a `Failed to read ` prefix that lives in a
transitive dependency, and the 204 case asserts an empty diag channel;
both would pass just as well against a dead observer. Each now proves
its own observer live first.
- **Flake-tested.** The 503 case's exact two-request assertion used to
turn on the jitter draw: standing a 500 ms collector delay in for a
loaded runner made it fail 20/20 (13 on the request count, 7 on
`Timeout` masking the error class). With the jitter pinned, 20/20 pass.

</details>


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **Bug Fixes**
- Improved telemetry export reliability by preventing unintended
compression and certificate-related environment settings from affecting
exporter configuration.
- Ensured telemetry attributes with null or undefined values are omitted
rather than serialized as misleading empty values.
- Improved handling of unreachable or unresponsive telemetry collectors
with bounded failures and appropriate diagnostic reporting.
- Preserved connection reuse and batching behavior for more efficient
OTLP delivery.

- **Tests**
- Added comprehensive coverage for OTLP formatting, authorization,
batching, compression, certificates, connection handling, and failure
scenarios.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-09-09 12:39:35 +02:00

524 lines
19 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as fs from "node:fs";
import * as path from "node:path";
import {
_resetConsentCacheForTest,
getConsentState,
markDisabled,
markEnabled,
writeConsentFlag,
warmTelemetryIdentity,
warmTelemetryIdentitySync,
resetLocalTelemetryState,
init,
isEnabled,
status,
shutdown,
track,
} from "../src/index.js";
import { getClient, resetClient } from "../src/otel.js";
import { _resetIdentityCacheForTest } from "../src/identity.js";
import { _resetBasePropsCacheForTest } from "../src/base-props.js";
import { scopeHome, snapshotEnv } from "./helpers.js";
import { configFilePath } from "../src/paths.js";
// Mock the OpenTelemetry Logs SDK. Each constructed LoggerProvider exposes the
// logger's `emit` and the provider's `shutdown` as spies so we can observe what
// track()/shutdown() drive, plus the batch-processor and exporter config — all
// without any network I/O.
interface ProviderInstance {
config: { resource: unknown; processors: unknown[] };
emit: ReturnType<typeof vi.fn>;
shutdown: ReturnType<typeof vi.fn>;
}
const otelMock = vi.hoisted(() => ({
providers: [] as ProviderInstance[],
processors: [] as Array<{ opts: Record<string, unknown> }>,
exporters: [] as Array<{ opts: Record<string, unknown> }>,
}));
// Telemetry resolves the host fingerprint internally for every entry point.
// Stub both the sync and async resolvers to a fixed 64-hex value so track() uses
// a deterministic id without spawning the real simulator-server binary. (The
// async resolver backs the background upgrade / warm-up; with the fingerprint
// resolved synchronously here it is a no-op, but the export must exist.)
vi.mock("../src/fingerprint.js", () => ({
resolveHostFingerprint: () => "f".repeat(64),
resolveHostFingerprintAsync: () => Promise.resolve("f".repeat(64)),
}));
vi.mock("@opentelemetry/api-logs", () => ({ SeverityNumber: { INFO: 9 } }));
vi.mock("@opentelemetry/resources", () => ({
resourceFromAttributes: (attributes: Record<string, unknown>) => ({ attributes }),
}));
vi.mock("@opentelemetry/exporter-logs-otlp-http", () => ({
OTLPLogExporter: vi.fn().mockImplementation(function (this: { opts: unknown }, opts: unknown) {
this.opts = opts;
otelMock.exporters.push(this as (typeof otelMock.exporters)[number]);
}),
}));
vi.mock("@opentelemetry/sdk-logs", () => ({
BatchLogRecordProcessor: vi.fn().mockImplementation(function (
this: { opts: unknown },
opts: unknown
) {
this.opts = opts;
otelMock.processors.push(this as (typeof otelMock.processors)[number]);
}),
LoggerProvider: vi.fn().mockImplementation(function (
this: ProviderInstance,
config: ProviderInstance["config"]
) {
const emit = vi.fn();
const shutdown = vi.fn().mockResolvedValue(undefined);
Object.assign(this, { config, emit, shutdown, getLogger: () => ({ emit }) });
otelMock.providers.push(this);
}),
}));
/** The attributes of the Nth emitted log record on a provider. */
const attrsOf = (provider: ProviderInstance, n: number): Record<string, unknown> =>
(provider.emit.mock.calls[n]![0] as { attributes: Record<string, unknown> }).attributes;
describe("telemetry public surface", () => {
const { tmp } = scopeHome();
beforeEach(() => {
otelMock.providers.length = 0;
otelMock.processors.length = 0;
otelMock.exporters.length = 0;
resetClient();
// is_ci and friends are memoized per process; reset so a test that sets CI
// env vars sees them recomputed rather than a value cached by a prior test.
_resetBasePropsCacheForTest();
// Isolate the module-level id / fingerprint / consent caches between tests.
_resetIdentityCacheForTest();
_resetConsentCacheForTest();
(globalThis as Record<string, unknown>).__ARGENT_OTEL_TOKEN_TEST = "otel_real";
init("tool_server");
markEnabled();
});
// Helpers for the identity-wiring tests below.
const idFile = () => path.join(tmp(), ".argent", "telemetry-id");
const readId = () => {
try {
return fs.readFileSync(idFile(), "utf8").trim();
} catch {
return null;
}
};
const flushUpgrade = async () => {
for (let i = 0; i < 6; i++) await Promise.resolve();
await new Promise((r) => setTimeout(r, 0));
};
afterEach(() => {
delete (globalThis as Record<string, unknown>).__ARGENT_OTEL_TOKEN_TEST;
resetClient();
vi.restoreAllMocks();
});
it("markDisabled persists disabled state and drains prior events without emitting an opt-out event", async () => {
track("toolserver:start", {});
const provider = otelMock.providers[0]!;
provider.shutdown.mockImplementation(async () => {
expect(isEnabled()).toBe(false);
});
await markDisabled();
expect(otelMock.providers).toHaveLength(1);
expect(provider.emit).toHaveBeenCalledTimes(1);
expect(provider.emit).toHaveBeenCalledWith(
expect.objectContaining({ body: "toolserver:start" })
);
// Opting out emits nothing extra — only the one event that was already queued.
expect(provider.shutdown).toHaveBeenCalledTimes(1);
expect(isEnabled()).toBe(false);
});
it("does not provision the anon-id file when the ingest token is unusable", () => {
// An intentionally-disabled/empty token means nothing can ever transmit, so
// track() must not write a persistent identifier to the user's disk.
(globalThis as Record<string, unknown>).__ARGENT_OTEL_TOKEN_TEST = "";
resetClient();
track("toolserver:start", {});
expect(otelMock.providers).toHaveLength(0);
expect(status().hasAnonIdOnDisk).toBe(false);
});
it("track queues without flushing so command shutdown drains later", async () => {
track("toolserver:start", {});
track("toolserver:stop", {
reason: "signal",
uptime_ms: 1,
total_tool_calls: 0,
});
const provider = otelMock.providers[0]!;
expect(otelMock.providers).toHaveLength(1);
expect(provider.emit).toHaveBeenCalledTimes(2);
expect(provider.emit).toHaveBeenCalledWith(
expect.objectContaining({ body: "toolserver:stop" })
);
expect(provider.shutdown).not.toHaveBeenCalled();
// The batching config lives on the batch log-record processor.
expect(otelMock.processors[0]!.opts).toEqual(
expect.objectContaining({ maxExportBatchSize: 20, scheduledDelayMillis: 10_000 })
);
});
it("uses the host fingerprint verbatim as the distinctId", () => {
// The fingerprint module is stubbed (top of file) to a fixed 64-hex value,
// so the id is that fingerprint rather than a random v4.
track("toolserver:start", {});
const provider = otelMock.providers[0]!;
expect(provider.emit).toHaveBeenCalledTimes(1);
const distinctId = attrsOf(provider, 0).distinct_id;
// The distinctId is the fingerprint hash itself, not a random v4 UUID.
expect(distinctId).toBe("f".repeat(64));
// Persisted to disk as the migrated id.
expect(status().anonIdPrefix).toBe("f".repeat(64).slice(0, 8));
// Migration is local-only: exactly one record is emitted (the tracked event),
// never an extra identity/alias record.
expect(provider.emit).toHaveBeenCalledTimes(1);
});
it("self-heals a fallback id to the fingerprint via the background upgrade wired into track()", async () => {
// Regression guard for the reviewer's C2: a legacy random id on disk. The
// first event emits under it (no blocking migrate), then buildPayload's
// scheduleFingerprintUpgrade (fed the async resolver, mocked to the
// fingerprint) migrates the on-disk id so subsequent events converge. If the
// scheduleFingerprintUpgrade call in buildPayload were removed, the on-disk
// id would never migrate and the second event would still be the legacy id.
const LEGACY = "11111111-1111-4111-8111-111111111111";
const FP = "f".repeat(64);
fs.mkdirSync(path.join(tmp(), ".argent"), { recursive: true });
fs.writeFileSync(idFile(), LEGACY, { mode: 0o600 });
track("toolserver:start", {});
const provider = otelMock.providers[0]!;
expect(attrsOf(provider, 0).distinct_id).toBe(LEGACY);
await flushUpgrade();
expect(readId()).toBe(FP); // background upgrade migrated the file
track("toolserver:start", {});
expect(attrsOf(provider, 1).distinct_id).toBe(FP);
});
it("warmTelemetryIdentity establishes the fingerprint id off the hot path", async () => {
expect(status().hasAnonIdOnDisk).toBe(false);
await warmTelemetryIdentity();
// The async resolver (mocked) yields the fingerprint, persisted before any event.
expect(readId()).toBe("f".repeat(64));
// A subsequent event then finds it on disk (no truly-fresh sync resolve).
track("toolserver:start", {});
const provider = otelMock.providers[0]!;
expect(attrsOf(provider, 0).distinct_id).toBe("f".repeat(64));
});
it("warmTelemetryIdentity mints no identity when telemetry is disabled", async () => {
writeConsentFlag(false);
_resetConsentCacheForTest();
expect(isEnabled()).toBe(false);
await warmTelemetryIdentity();
// A disabled machine must not have a persistent identifier provisioned.
expect(readId()).toBeNull();
expect(status().hasAnonIdOnDisk).toBe(false);
});
it("warmTelemetryIdentity provisions no identity when the ingest token is unusable", async () => {
// Consent-enabled but an unusable token: like track()/buildPayload, warm-up must
// resolve the client first and bail before spawning the fingerprint binary or
// writing a durable per-machine id for events that can never be transmitted.
// The async resolver is mocked to a fingerprint, so without the getClient()
// gate warmIdentity would persist "f".repeat(64); asserting no id proves the
// short-circuit.
(globalThis as Record<string, unknown>).__ARGENT_OTEL_TOKEN_TEST = "";
resetClient();
_resetConsentCacheForTest();
expect(isEnabled()).toBe(true);
await warmTelemetryIdentity();
expect(readId()).toBeNull();
expect(status().hasAnonIdOnDisk).toBe(false);
});
// The SHORT-LIVED-CLI (installer) counterpart of warmTelemetryIdentity, wired
// into `argent init`/`update` so the very first event (cli_init_start) carries
// the stable fingerprint instead of a random fallback the background upgrade
// would only migrate to afterward.
it("warmTelemetryIdentitySync migrates a legacy fallback to the fingerprint before the first event (the cli_init_start fix)", () => {
const LEGACY = "11111111-1111-4111-8111-111111111111";
const FP = "f".repeat(64);
fs.mkdirSync(path.join(tmp(), ".argent"), { recursive: true });
fs.writeFileSync(idFile(), LEGACY, { mode: 0o600 });
// Without the sync warm, track() would serve the legacy fallback (hot-path
// contract). The warm forces the resolve+migrate up front...
warmTelemetryIdentitySync();
expect(readId()).toBe(FP);
// ...so the first tracked event already carries the fingerprint.
track("installation:cli_init_start", {
package_manager: "npm",
is_non_interactive: false,
});
const provider = otelMock.providers[0]!;
expect(attrsOf(provider, 0).distinct_id).toBe(FP);
});
it("warmTelemetryIdentitySync mints no identity when telemetry is disabled", () => {
writeConsentFlag(false);
_resetConsentCacheForTest();
expect(isEnabled()).toBe(false);
warmTelemetryIdentitySync();
expect(readId()).toBeNull();
expect(status().hasAnonIdOnDisk).toBe(false);
});
it("warmTelemetryIdentitySync provisions no identity when the ingest token is unusable", () => {
(globalThis as Record<string, unknown>).__ARGENT_OTEL_TOKEN_TEST = "";
resetClient();
_resetConsentCacheForTest();
expect(isEnabled()).toBe(true);
warmTelemetryIdentitySync();
expect(readId()).toBeNull();
expect(status().hasAnonIdOnDisk).toBe(false);
});
it("captures events in CI and annotates payloads with is_ci", () => {
const restore = snapshotEnv(["CI"]);
try {
process.env.CI = "1";
track("toolserver:start", {});
const provider = otelMock.providers[0]!;
expect(provider.emit).toHaveBeenCalledWith(
expect.objectContaining({
body: "toolserver:start",
attributes: expect.objectContaining({ is_ci: true }),
})
);
} finally {
restore();
}
});
it("shutdown drains the constructed client", async () => {
track("toolserver:start", {});
track("toolserver:stop", {
reason: "signal",
uptime_ms: 1,
total_tool_calls: 0,
});
const provider = otelMock.providers[0]!;
await shutdown();
expect(otelMock.providers).toHaveLength(1);
expect(provider.shutdown).toHaveBeenCalledTimes(1);
});
it("resetLocalTelemetryState removes local state without a delete-person event and leaves consent untouched", async () => {
track("toolserver:start", {});
expect(status().hasAnonIdOnDisk).toBe(true);
const result = await resetLocalTelemetryState();
const provider = otelMock.providers[0]!;
expect(otelMock.providers).toHaveLength(1);
// The reset is local-only: it emits nothing beyond the already-tracked event
// and never shuts the client down.
expect(provider.emit).toHaveBeenCalledTimes(1);
expect(provider.shutdown).not.toHaveBeenCalled();
expect(result.localIdRemoved).toBe(true);
expect(result.noticeReset).toBe(true);
expect(status().hasAnonIdOnDisk).toBe(false);
// Consent is deliberately left untouched — a lasting opt-out is `markDisabled()`.
expect(isEnabled()).toBe(true);
});
it("removes the local telemetry id file but the deterministic id re-derives on the next event while consent stays enabled", async () => {
fs.unlinkSync(configFilePath());
_resetConsentCacheForTest();
track("toolserver:start", {});
const derivedId = readId();
const result = await resetLocalTelemetryState();
const provider = otelMock.providers[0]!;
// The reset itself emits no erasure record — it is a purely local removal.
expect(provider.emit).toHaveBeenCalledTimes(1);
expect(result.localIdRemoved).toBe(true);
expect(status().hasAnonIdOnDisk).toBe(false);
// No config file is created just to clear a marker that was never set.
expect(fs.existsSync(configFilePath())).toBe(false);
expect(isEnabled()).toBe(true);
// Deleting the id file with consent still enabled is a LOCAL removal, not a
// permanent erasure: the fingerprint-derived id is re-created identically on
// the next tracked event. A genuine reset comes from the opt-out path
// (`markDisabled()` / `argent telemetry disable`).
track("toolserver:start", {});
expect(status().hasAnonIdOnDisk).toBe(true);
expect(readId()).toBe(derivedId);
});
it("leaves an explicit opt-out untouched", async () => {
fs.writeFileSync(configFilePath(), JSON.stringify({ telemetry: { enabled: false } }) + "\n");
_resetConsentCacheForTest();
await resetLocalTelemetryState();
expect(getConsentState({}).enabled).toBe(false);
expect(getConsentState({}).source.source).toBe("config_file");
});
it("leaves an explicit opt-in untouched", async () => {
markEnabled();
await resetLocalTelemetryState();
expect(getConsentState({}).enabled).toBe(true);
expect(getConsentState({}).source.source).toBe("config_file");
});
it("removes the local telemetry id", async () => {
track("toolserver:start", {});
expect(status().hasAnonIdOnDisk).toBe(true);
const result = await resetLocalTelemetryState();
expect(result.localIdRemoved).toBe(true);
expect(status().hasAnonIdOnDisk).toBe(false);
});
});
describe("the log record that goes on the wire", () => {
const { tmp: _tmp } = scopeHome();
beforeEach(() => {
otelMock.providers.length = 0;
otelMock.processors.length = 0;
otelMock.exporters.length = 0;
resetClient();
_resetBasePropsCacheForTest();
_resetIdentityCacheForTest();
_resetConsentCacheForTest();
(globalThis as Record<string, unknown>).__ARGENT_OTEL_TOKEN_TEST = "otel_real";
init("tool_server");
markEnabled();
});
afterEach(() => {
delete (globalThis as Record<string, unknown>).__ARGENT_OTEL_TOKEN_TEST;
resetClient();
vi.restoreAllMocks();
});
it("routes properties through the sanitizer's allowlist", () => {
// `toolserver:start` allows no properties at all, so an absolute path handed
// to it must not survive. This is what pins track() to sanitize(): read the
// caller's props straight onto the record and the path ships verbatim.
track("toolserver:start", { cwd: "/Users/someone/private-project" } as never);
const attributes = attrsOf(otelMock.providers[0]!, 0);
expect(attributes).not.toHaveProperty("cwd");
expect(Object.values(attributes)).not.toContain("/Users/someone/private-project");
});
it("omits a null-valued property rather than sending an explicit null", () => {
// The SDK would accept a null attribute and serialize it as an empty OTLP
// value, indistinguishable downstream from a property that really was empty;
// toAttributes drops the key instead.
const client = getClient()!;
client.emit({
distinctId: "d",
event: "toolserver:start",
properties: { kept: "yes", nulled: null, missing: undefined },
});
const attributes = attrsOf(otelMock.providers[0]!, 0);
expect(attributes.kept).toBe("yes");
expect("nulled" in attributes).toBe(false);
expect("missing" in attributes).toBe(false);
});
it("identifies the record by service.name resource and event.name attribute", () => {
track("toolserver:start", {});
const provider = otelMock.providers[0]!;
expect(
(provider.config.resource as { attributes: Record<string, unknown> }).attributes
).toEqual({ "service.name": "argent" });
expect(attrsOf(provider, 0)["event.name"]).toBe("toolserver:start");
});
});
describe("drain budgets", () => {
const { tmp: _t } = scopeHome();
beforeEach(() => {
otelMock.providers.length = 0;
otelMock.processors.length = 0;
otelMock.exporters.length = 0;
resetClient();
_resetBasePropsCacheForTest();
_resetIdentityCacheForTest();
_resetConsentCacheForTest();
(globalThis as Record<string, unknown>).__ARGENT_OTEL_TOKEN_TEST = "otel_real";
init("tool_server");
markEnabled();
});
afterEach(() => {
vi.useRealTimers();
delete (globalThis as Record<string, unknown>).__ARGENT_OTEL_TOKEN_TEST;
resetClient();
vi.restoreAllMocks();
});
/**
* Both drains race the client against a timer. The timer has to sit strictly
* later than the exporter's own export deadline (EXPORT_TIMEOUT_MS, equal to
* this budget), or it fires at the same instant and the batch is abandoned
* rather than given the chance to finish failing.
*/
async function assertRacedWithGrace(drain: () => Promise<unknown>): Promise<void> {
vi.useFakeTimers();
track("toolserver:start", {});
otelMock.providers[0]!.shutdown.mockImplementation(() => new Promise(() => {}));
let settled = false;
const pending = drain().then(() => {
settled = true;
});
await vi.advanceTimersByTimeAsync(1_500);
expect(settled, "resolved at the export deadline, leaving no grace").toBe(false);
await vi.advanceTimersByTimeAsync(250);
await pending;
expect(settled).toBe(true);
}
it("bounds shutdown() so a wedged exporter cannot stall a command", async () => {
await assertRacedWithGrace(() => shutdown());
});
it("bounds markDisabled()'s drain on the same budget as shutdown()", async () => {
await assertRacedWithGrace(() => markDisabled());
});
});