diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..e62788b --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +dist/** linguist-generated=true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4eab6a8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,44 @@ +name: CI + +# pull_request only (never pull_request_target): a fork's PR runs with no secrets and a +# read-only token. Nothing is published from here. +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + # A main commit can be pinned by SHA, so its run always finishes; only PR runs are superseded. + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + ci: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm typecheck + - run: pnpm test + # Apps install this repo by commit, so the committed dist/ must be exactly a fresh build. + - name: dist/ matches a fresh build + run: | + pnpm build + git diff --exit-code -- dist + untracked="$(git status --porcelain -- dist)" + if [ -n "$untracked" ]; then + echo "dist/ differs from a fresh build:" + echo "$untracked" + exit 1 + fi diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ab68e59 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +.claude/worktrees/ +*.tgz diff --git a/README.md b/README.md new file mode 100644 index 0000000..7250e17 --- /dev/null +++ b/README.md @@ -0,0 +1,168 @@ +# @codelittinc/otel-node + +In-process OpenTelemetry for Node services: SDK boot (traces, metrics, OTLP logs, Pyroscope +profiles), layer 1 of the two-layer PII scrub, a pino logger whose scrubbed lines are exported +as OTLP logs, and trace-context helpers. Extracted from Backbone's `packages/telemetry`. + +Telemetry exports only when `OTEL_EXPORTER_OTLP_ENDPOINT` is set. Without it nothing is exported +and no SDK module is loaded, so local runs, CI and tests stay silent and cheap. + +## Install by commit + +Nothing is published. Install the repo as a git dependency pinned to a commit; the built +`dist/` is committed, so there is no build step and no token is needed: + +```json +{ + "dependencies": { + "@codelittinc/otel-node": "github:codelittinc/otel-node#" + } +} +``` + +Pin a full commit SHA, never a branch. To upgrade, move the SHA. + +## Start telemetry + +Start it before the app loads `http`, from a file run with `node --import`: + +```ts +// src/instrumentation.ts, run as: node --import ./dist/instrumentation.js dist/index.js +import { PrismaInstrumentation } from "@prisma/instrumentation"; +import { startTelemetry } from "@codelittinc/otel-node"; + +await startTelemetry({ + serviceName: "api", + logScope: "acp", + metricViews: { instrumentPrefix: "acp.", allowedAttributes: ["queue", "outcome", "route"] }, + instrumentations: [new PrismaInstrumentation()], + redactKeys: ["unitLabel"], + onError: (err, context) => reportToErrorTracker(err, context), + ignoredPaths: ["/health"], +}); +``` + +An app with no options to pass can use the register entry instead. It reads +`OTEL_SERVICE_NAME`, which is also its log scope: + +```sh +OTEL_SERVICE_NAME=acp-worker node --import @codelittinc/otel-node/register dist/worker.js +``` + +`startTelemetry` never throws: a failed start, or options it cannot read, logs +`telemetry_start_failed` and the app runs on. Telemetry starts once per process: the first call's +options win whether or not it exports, and a later call with different options is ignored with a +`telemetry_restart_ignored` warning. +`shutdownTelemetry(timeoutMs = 5000)` flushes spans, metrics, log records and profiles, bounded +so a dead collector cannot stall a shutdown. + +## Options + +| Option | Default | What it does | +| ------------------ | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `serviceName` | required | `service.name`, unless `OTEL_SERVICE_NAME` is set. | +| `logScope` | `serviceName` | The OTel logger scope of exported log records. Only records from this scope are exported. Also names the BullMQ tracer and the `.request_id` span attribute. | +| `metricViews` | none | `{ instrumentPrefix, allowedAttributes }`: instruments named `*` export only those attributes. Without it, every attribute is kept. | +| `instrumentations` | `[]` | Appended to the package's list (http, undici, ioredis, pg, runtime-node). Pass Prisma here, at the app's own version. | +| `redactKeys` | `[]` | Key names (whole name, any case) redacted on top of the default PII key list: log fields, and span attributes by whole name or last `.` segment. A fixed list of attributes the instrumentations emit (`db.system.name`, `http.route`, `bullmq.job.id`, ...) is matched by whole name only, so `name` never wipes them; any other attribute, under any namespace, fails closed. Prisma's bare `name`, `method` and `model` are matched by whole name, so an app key `name` also redacts Prisma's span name attribute. It can only add; no default is ever removed. The agent's layer-2 scrub does not repeat them. | +| `onError` | none | `(err, { service })`: receives each fatal error `installFatalHandlers` sees when it is given no `report`. Wire your error tracker here. | +| `ignoredPaths` | `[]` | Incoming paths that open no server span; each covers itself and everything below it. | +| `esmLoaderHook` | `true` | `false` for a host that runs bundled CommonJS (Next.js). | +| `logs` | `true` | `false` keeps log lines on stdout only. | +| `env` | `process.env` | Read instead of the process environment (tests). | + +Environment: `OTEL_EXPORTER_OTLP_ENDPOINT` (on/off), `OTEL_SDK_DISABLED=true` (kill switch), +`OTEL_SERVICE_NAME`, `OTEL_LOG_LEVEL` (SDK debug logging), `LOG_LEVEL`, +`PYROSCOPE_SERVER_ADDRESS` (profiling, independent of the endpoint), and on Railway +`RAILWAY_GIT_COMMIT_SHA`, `RAILWAY_DEPLOYMENT_ID`, `RAILWAY_ENVIRONMENT_NAME` and +`RAILWAY_REPLICA_ID` for the resource. + +## Entries + +| Import | What | +| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `@codelittinc/otel-node` | `startTelemetry`, `shutdownTelemetry`, `isTelemetryStarted`, `setHttpRoute`, `getMeter`, `log`, `configureLogger`, `flushLog`, `captureConsole`, `installFatalHandlers`, `boundedLabel`, `currentTraceparent`, `withLinkedSpan`, `withRootSpan`, and the `TelemetryOptions`, `TelemetryConfig`, `TelemetryEnv`, `MetricViewOptions`, `ErrorHookContext`, `Meter` and `BatchObservableResult` types | +| `@codelittinc/otel-node/bullmq` | `bullmqTelemetry()`: the adapter for a BullMQ Queue or Worker `telemetry` option | +| `@codelittinc/otel-node/register` | `node --import` entry, no options | +| `@codelittinc/otel-node/testing` | `createTestLogger()`: the real logger, capturing parsed records in memory | +| `@codelittinc/otel-node/url-credentials` | URL userinfo and query scrub. Imports nothing from Node or OpenTelemetry, so a browser bundle can use it | + +`bullmq` (`^6.3.8`) is an optional peer dependency, used only by the `./bullmq` entry, so an app +without it typechecks the main entry even with `skipLibCheck` off. `bullmqTelemetry()` returns +the adapter once telemetry has started, and `undefined` before. + +## Logging + +`log` is the one pino logger a process shares. Each entry point calls +`configureLogger({ service })` once; modules log through a child that binds `component` +(`log.child({ component: "db" })`). Every line is scrubbed before it is written (below), then the +same scrubbed line is copied to OTLP logs. + +An app that logs with `console` calls `captureConsole()` once at startup. Every `console.warn` and +`console.error` line still reaches the console unchanged, and a scrubbed copy is exported under +the log scope. Each argument is first reduced to what `log` would export for it (PII keys +redacted, no function, Map or Set contents), and the formatted line is scrubbed last, so the +exported copy never holds more than a `log` line would. `console.log` and `console.info` are left +alone. The console copy on stdout is not scrubbed: move a line that can carry personal data to +`log`. The SDK's own diagnostics (`OTEL_LOG_LEVEL`) bypass the capture and are never exported. + +## The two-layer scrub + +Personal data is removed twice before it is stored. + +1. **Layer 1, in the process (this package).** The logger redacts PII and credential keys at any + depth (`ssn`, `email`, `phone`, names, addresses, account and routing numbers, tokens, secrets, + plus `redactKeys`), serializes errors deny-by-default, and scrubs SSN shapes, emails and URL + userinfo and queries from every string. The span exporter drops request and response bodies + and headers, bound SQL values and Redis statements, redacts exception messages, strips URL + queries and userinfo, replaces a URL path with its route template, and redacts any attribute + named by `redactKeys`. +2. **Layer 2, in the agent.** The Alloy agent in `codelittinc/carbon-observability` repeats the + pattern scrub on every span, log and metric before it leaves the host. It repeats only this + package's defaults (the contract below), never an app's `redactKeys`: for those, layer 1 is + the only scrub. + +`src/scrub-contract.json` pins every layer-1 pattern and key set the agent repeats. This repo is +its source; `codelittinc/carbon-observability` follows with a copy at +`agent/contracts/otel-node-scrub-contract.json`. Changing a scrub pattern fails +`scrub-contract.test.ts` until the contract is updated here, then the agent's copy and its +`scrub.alloy` and `scrub-logs-metrics.alloy` follow in a PR there. + +## Rules for an app using this package + +1. **No PII in span or event attributes, span names, or log fields.** Record identifiers go on + spans and logs only, never on metrics. +2. **Metric labels come from fixed sets:** route template, method, status, queue, kind. Never a + raw path, an id, or an error message. Set `metricViews` so an app instrument exports only the + labels in `allowedAttributes`; a label missing there is dropped. `boundedLabel` collapses a + value outside an allowlist to `"other"`. +3. **Money is never a metric value.** Count events instead. +4. **Every instrumentation is checked against the span scrub.** One added to this package needs a + scrub test in `redacting-exporter.test.ts` before it merges. An app that passes its own + through `instrumentations` (Prisma, for example) checks the attributes it emits: an attribute + key that carries values the scrub does not already drop or redact needs a PR here, adding the + key to the scrub with a test, before the app ships it. `redactKeys` covers a key in the + meantime, in layer 1 only. +5. **Only the scrubbed log line is exported.** Records are exported only from the log scope, built + from the shared logger's already scrubbed output; OTel's own diagnostics + (`event: "otel_diag"`) stay on stdout. Never add a logger instrumentation or a log record + processor that sees a record before the logger scrubs it. +6. **Only process entry points boot the SDK and call `installFatalHandlers`.** Libraries and + shared packages import only the logger API (`log`, `configureLogger`, `flushLog`) and + `currentTraceparent`. The `Meter` and `BatchObservableResult` types let callers type + instruments without `@opentelemetry/api`. +7. **Keep `@opentelemetry/*` versions in lockstep with this package.** Two versions of one OTel + package in an app's tree register two providers, and one of them silently never exports. + +## Development + +Node 22 and pnpm. + +```sh +pnpm install +pnpm typecheck +pnpm test +pnpm build # then commit dist/ +``` + +CI runs the type check, the tests, and a fresh build that must match the committed `dist/`. diff --git a/dist/app-keys.d.ts b/dist/app-keys.d.ts new file mode 100644 index 0000000..e4f40b6 --- /dev/null +++ b/dist/app-keys.d.ts @@ -0,0 +1,20 @@ +/** Adds key names (matched whole, any letter case); throws on a key that is not a string. */ +export declare function addAppKeys(keys: Iterable): void; +/** Test seam: forgets the app-supplied keys. */ +export declare function resetAppKeys(): void; +/** True when `key` is an app-supplied key name, in any letter case. */ +export declare function isAppKey(key: string): boolean; +/** + * Attributes the package's instrumentations, BullMQ and Prisma emit whose last segment is a + * plausible generic app key (`name`, `id`, `method`). Only these exact names skip last-segment + * matching, so `name` never wipes `db.system.name`, while an app's own `http.unitLabel` is still + * redacted. Content-bearing attributes (`url.full`, `db.query.text`, job options and reasons) are + * deliberately absent. Prisma's bare `name`, `method` and `model` are not exempt: a whole-name app + * key matches them. + */ +export declare const EXEMPT_ATTRIBUTES: ReadonlySet; +/** + * A span attribute is an app key when its whole name is one, or its last `.` segment is one, + * unless it is an exempt instrumentation attribute. + */ +export declare function isAppAttributeKey(key: string): boolean; diff --git a/dist/app-keys.js b/dist/app-keys.js new file mode 100644 index 0000000..349278a --- /dev/null +++ b/dist/app-keys.js @@ -0,0 +1,104 @@ +/** App-supplied key names, lower-cased; redacted in log fields and span attributes. */ +const appKeys = new Set(); +/** Adds key names (matched whole, any letter case); throws on a key that is not a string. */ +export function addAppKeys(keys) { + const names = [...keys].map((key) => { + if (typeof key !== "string") + throw new TypeError("redactKeys must hold strings"); + return key.trim().toLowerCase(); + }); + for (const name of names) + if (name) + appKeys.add(name); +} +/** Test seam: forgets the app-supplied keys. */ +export function resetAppKeys() { + appKeys.clear(); +} +/** True when `key` is an app-supplied key name, in any letter case. */ +export function isAppKey(key) { + return appKeys.size > 0 && appKeys.has(key.toLowerCase()); +} +/** + * Attributes the package's instrumentations, BullMQ and Prisma emit whose last segment is a + * plausible generic app key (`name`, `id`, `method`). Only these exact names skip last-segment + * matching, so `name` never wipes `db.system.name`, while an app's own `http.unitLabel` is still + * redacted. Content-bearing attributes (`url.full`, `db.query.text`, job options and reasons) are + * deliberately absent. Prisma's bare `name`, `method` and `model` are not exempt: a whole-name app + * key matches them. + */ +export const EXEMPT_ATTRIBUTES = new Set([ + // http and undici + "http.request.method", + "http.request.method_original", + "http.response.status_code", + "http.route", + "server.address", + "server.port", + "network.peer.port", + "network.protocol.version", + "url.scheme", + "error.type", + "user_agent.synthetic.type", + // ioredis, pg and Prisma + "db.system.name", + "db.operation.name", + "db.namespace", + "db.client.connection.pool.name", + "db.client.connection.state", + // messaging semantic conventions + "messaging.destination.name", + // bullmq: names and identifiers + "bullmq.queue.name", + "bullmq.queue.operation", + "bullmq.job.name", + "bullmq.job.id", + "bullmq.job.ids", + "bullmq.job.key", + "bullmq.job.type", + "bullmq.job.state", + "bullmq.job.scheduler.id", + "bullmq.worker.name", + "bullmq.worker.id", + "bullmq.flow.name", + "bullmq.queue.jobs.state", + // bullmq: counts and timings + "bullmq.job.attempts.made", + "bullmq.job.bulk.count", + "bullmq.job.processed.timestamp", + "bullmq.job.attempt_finished_timestamp", + "bullmq.job.duration", + "bullmq.queue.drain.delay", + "bullmq.queue.grace", + "bullmq.queue.clean.limit", + "bullmq.queue.clean.count", + "bullmq.queue.rate.limit", + "bullmq.queue.event.max.length", + "bullmq.worker.rate.limit", + "bullmq.worker.do.not.wait.active", + "bullmq.worker.force.close", + "bullmq.worker.stalled.jobs", + "bullmq.worker.failed.jobs", + "bullmq.worker.jobs.to.extend.locks", + "bullmq.jobs.completed", + "bullmq.jobs.failed", + "bullmq.jobs.delayed", + "bullmq.jobs.retried", + "bullmq.jobs.waiting", + "bullmq.jobs.waiting_children", + // exception events + "exception.type", +]); +/** + * A span attribute is an app key when its whole name is one, or its last `.` segment is one, + * unless it is an exempt instrumentation attribute. + */ +export function isAppAttributeKey(key) { + if (appKeys.size === 0) + return false; + if (isAppKey(key)) + return true; + if (EXEMPT_ATTRIBUTES.has(key)) + return false; + return isAppKey(key.slice(key.lastIndexOf(".") + 1)); +} diff --git a/dist/bounded.d.ts b/dist/bounded.d.ts new file mode 100644 index 0000000..419ba88 --- /dev/null +++ b/dist/bounded.d.ts @@ -0,0 +1,7 @@ +/** The label every value outside an allowlist collapses into. */ +export declare const OTHER_LABEL = "other"; +/** + * A metric label from a closed set: the value itself when the allowlist holds it, otherwise + * `"other"`. Keeps raw paths, ids and error text from ever becoming a label value. + */ +export declare function boundedLabel(value: unknown, allowlist: ReadonlySet | readonly T[]): T | typeof OTHER_LABEL; diff --git a/dist/bounded.js b/dist/bounded.js new file mode 100644 index 0000000..347a14e --- /dev/null +++ b/dist/bounded.js @@ -0,0 +1,14 @@ +/** The label every value outside an allowlist collapses into. */ +export const OTHER_LABEL = "other"; +/** + * A metric label from a closed set: the value itself when the allowlist holds it, otherwise + * `"other"`. Keeps raw paths, ids and error text from ever becoming a label value. + */ +export function boundedLabel(value, allowlist) { + if (typeof value !== "string") + return OTHER_LABEL; + const allowed = allowlist instanceof Set + ? allowlist.has(value) + : allowlist.includes(value); + return allowed ? value : OTHER_LABEL; +} diff --git a/dist/bullmq.d.ts b/dist/bullmq.d.ts new file mode 100644 index 0000000..a62fd9e --- /dev/null +++ b/dist/bullmq.d.ts @@ -0,0 +1,6 @@ +import type { Telemetry } from "bullmq"; +/** + * BullMQ telemetry adapter for a Queue or Worker `telemetry` option. Undefined until + * telemetry has started, which leaves BullMQ on its untraced code path. + */ +export declare function bullmqTelemetry(): Telemetry | undefined; diff --git a/dist/bullmq.js b/dist/bullmq.js new file mode 100644 index 0000000..3891deb --- /dev/null +++ b/dist/bullmq.js @@ -0,0 +1,8 @@ +import { startedBullmqTelemetry } from "./sdk.js"; +/** + * BullMQ telemetry adapter for a Queue or Worker `telemetry` option. Undefined until + * telemetry has started, which leaves BullMQ on its untraced code path. + */ +export function bullmqTelemetry() { + return startedBullmqTelemetry(); +} diff --git a/dist/config.d.ts b/dist/config.d.ts new file mode 100644 index 0000000..0712635 --- /dev/null +++ b/dist/config.d.ts @@ -0,0 +1,10 @@ +export type TelemetryEnv = Partial>; +export interface TelemetryConfig { + enabled: boolean; + serviceName: string; + serviceVersion: string; + environment: string; + serviceInstanceId: string; +} +/** Telemetry exports only when an OTLP endpoint is set; local runs, CI and tests stay silent. */ +export declare function resolveTelemetryConfig(fallbackServiceName: string, env?: TelemetryEnv): TelemetryConfig; diff --git a/dist/config.js b/dist/config.js new file mode 100644 index 0000000..730f1b3 --- /dev/null +++ b/dist/config.js @@ -0,0 +1,17 @@ +import { randomUUID } from "node:crypto"; +/** One id per process, so every signal from this process names the same instance. */ +const PROCESS_INSTANCE_ID = randomUUID(); +/** Telemetry exports only when an OTLP endpoint is set; local runs, CI and tests stay silent. */ +export function resolveTelemetryConfig(fallbackServiceName, env = process.env) { + const endpoint = env.OTEL_EXPORTER_OTLP_ENDPOINT?.trim(); + const killed = env.OTEL_SDK_DISABLED?.trim().toLowerCase() === "true"; + return { + enabled: Boolean(endpoint) && !killed, + serviceName: env.OTEL_SERVICE_NAME?.trim() || fallbackServiceName, + // A `railway up` deploy carries no commit SHA; the deployment id still pins it. + serviceVersion: env.RAILWAY_GIT_COMMIT_SHA?.trim() || env.RAILWAY_DEPLOYMENT_ID?.trim() || "unknown", + environment: env.RAILWAY_ENVIRONMENT_NAME?.trim() || "unknown", + // Splits runtime panels by replica; Prometheus maps it to the `instance` label. + serviceInstanceId: env.RAILWAY_REPLICA_ID?.trim() || PROCESS_INSTANCE_ID, + }; +} diff --git a/dist/console-capture.d.ts b/dist/console-capture.d.ts new file mode 100644 index 0000000..3d57e7c --- /dev/null +++ b/dist/console-capture.d.ts @@ -0,0 +1,8 @@ +/** + * Sends every `console.warn` and `console.error` line to OTLP logs, through the logger's scrub and + * under the configured log scope, and still writes it to the console unchanged. Only the scrubbed + * copy is exported; stdout keeps the app's own line. Calling it again changes nothing. + */ +export declare function captureConsole(): void; +/** Puts back the console methods `captureConsole` replaced. */ +export declare function releaseConsole(): void; diff --git a/dist/console-capture.js b/dist/console-capture.js new file mode 100644 index 0000000..0882bf2 --- /dev/null +++ b/dist/console-capture.js @@ -0,0 +1,88 @@ +import { formatWithOptions } from "node:util"; +import { createLogger } from "./logger.js"; +import { safeErrorMessage } from "./log-errors.js"; +import { sanitizeLogValue } from "./log-redact.js"; +import { scrubErrorText } from "./log-scrub.js"; +import { hasLogLineSink } from "./log-sink.js"; +import { currentService } from "./shared-log.js"; +const LEVELS = ["warn", "error"]; +let originals; +/** + * Sends every `console.warn` and `console.error` line to OTLP logs, through the logger's scrub and + * under the configured log scope, and still writes it to the console unchanged. Only the scrubbed + * copy is exported; stdout keeps the app's own line. Calling it again changes nothing. + */ +export function captureConsole() { + if (originals) + return; + originals = {}; + // Writes nothing itself: the line reaches only the OTLP sink. + const logger = createLogger(currentService, { write: () => undefined }).child({ + component: "console", + }); + for (const level of LEVELS) { + const original = console[level]; + originals[level] = original; + console[level] = (...args) => { + original.apply(console, args); + exportLine(logger, level, args); + }; + } +} +/** Puts back the console methods `captureConsole` replaced. */ +export function releaseConsole() { + if (!originals) + return; + for (const level of LEVELS) { + const original = originals[level]; + if (original) + console[level] = original; + } + originals = undefined; +} +/** + * The first Error goes under `err`, and its safe message takes its place in the text, so a `%s` + * still reads as on the console. Every other argument is reduced to what the logger would export + * for it, then the arguments are formatted, and the rendered text is scrubbed last: a value split + * across arguments only takes its final shape once formatted. + */ +function exportLine(logger, level, args) { + if (!hasLogLineSink()) + return; + try { + const err = args.find((arg) => arg instanceof Error); + const parts = args.map((arg) => (arg === err ? safeErrorMessage(err) : asLogged(arg))); + const message = scrubErrorText(formatWithOptions({ colors: false }, ...parts)); + if (err === undefined) + logger[level](message); + else + logger[level]({ err }, message); + } + catch { + // the console line is already written + } +} +/** + * An argument as the logger's JSON line would carry it: sanitized (bounded, PII keys redacted), + * then through JSON, so console formatting has nothing left to print that the line would not + * hold. No function, Map or Set contents, getter, custom inspect or toString survives. + */ +function asLogged(arg) { + if (typeof arg === "function") + return "[Function]"; + const sanitized = sanitizeLogValue(arg); + if (typeof sanitized !== "object" || sanitized === null) + return sanitized; + const json = JSON.stringify(sanitized, jsonSafe); + return json === undefined ? undefined : JSON.parse(json); +} +/** A byte view becomes its size, a Map or Set `{}`, a bigint its digits. */ +function jsonSafe(_key, value) { + if (typeof value === "bigint") + return value.toString(); + if (ArrayBuffer.isView(value)) + return { bytes: value.byteLength }; + if (value instanceof Map || value instanceof Set) + return {}; + return value; +} diff --git a/dist/fatal.d.ts b/dist/fatal.d.ts new file mode 100644 index 0000000..6d5c982 --- /dev/null +++ b/dist/fatal.d.ts @@ -0,0 +1,20 @@ +interface FatalOptions { + /** Synchronous: stop taking new work (a BullMQ `worker.close()`, not awaited). */ + stopIntake?: () => void; + /** + * Reports the fatal error; called once, before the flush. Defaults to the `onError` hook given + * to `startTelemetry`. + */ + report?: (err: unknown) => void; + /** Flushes telemetry; raced against the same budget as the log flush. */ + flushTelemetry?: (timeoutMs: number) => Promise; + exit?: (code: number) => void; + target?: Pick; +} +/** + * Logs an uncaught exception or unhandled rejection as one JSON line under `err`, stops intake, + * reports it, spends at most two seconds flushing, then exits 1. A second fatal while exiting is + * logged only. + */ +export declare function installFatalHandlers(options?: FatalOptions): void; +export {}; diff --git a/dist/fatal.js b/dist/fatal.js new file mode 100644 index 0000000..0285267 --- /dev/null +++ b/dist/fatal.js @@ -0,0 +1,46 @@ +import { flushLog, log } from "./shared-log.js"; +import { reportError } from "./options.js"; +/** Total time a fatal exit may spend flushing logs and telemetry. */ +const FATAL_FLUSH_MS = 2_000; +/** + * Logs an uncaught exception or unhandled rejection as one JSON line under `err`, stops intake, + * reports it, spends at most two seconds flushing, then exits 1. A second fatal while exiting is + * logged only. + */ +export function installFatalHandlers(options = {}) { + const { stopIntake, report = (err) => reportError(err), flushTelemetry, exit = (code) => process.exit(code), } = options; + const target = options.target ?? process; + let exiting = false; + const fatal = (err, message) => { + try { + log.fatal({ err }, message); + } + catch { + // the exit still has to happen + } + if (exiting) + return; + exiting = true; + process.exitCode = 1; + try { + stopIntake?.(); + } + catch { + // a failing close must not keep a crashed process alive + } + try { + report(err); + } + catch { + // reporting must not keep a crashed process alive + } + const budget = new Promise((resolve) => setTimeout(resolve, FATAL_FLUSH_MS).unref()); + const flushes = Promise.allSettled([ + flushLog(FATAL_FLUSH_MS), + Promise.resolve().then(() => flushTelemetry?.(FATAL_FLUSH_MS)), + ]); + void Promise.race([flushes, budget]).then(() => exit(1)); + }; + target.on("unhandledRejection", (reason) => fatal(reason, "unhandled rejection")); + target.on("uncaughtException", (err) => fatal(err, "uncaught exception")); +} diff --git a/dist/http-route.d.ts b/dist/http-route.d.ts new file mode 100644 index 0000000..a07afe6 --- /dev/null +++ b/dist/http-route.d.ts @@ -0,0 +1,6 @@ +/** + * Labels the active HTTP server span and its request metrics with a route template, and tags the + * span with `.request_id`. Call it once routing has finished; a no-op when no span or + * HTTP context is active. + */ +export declare function setHttpRoute(route: string, method: string, requestId?: string): void; diff --git a/dist/http-route.js b/dist/http-route.js new file mode 100644 index 0000000..a07eb5b --- /dev/null +++ b/dist/http-route.js @@ -0,0 +1,17 @@ +import { context, trace } from "@opentelemetry/api"; +import { getRPCMetadata, RPCType } from "@opentelemetry/core"; +import { logScope } from "./options.js"; +/** + * Labels the active HTTP server span and its request metrics with a route template, and tags the + * span with `.request_id`. Call it once routing has finished; a no-op when no span or + * HTTP context is active. + */ +export function setHttpRoute(route, method, requestId) { + const rpc = getRPCMetadata(context.active()); + if (rpc?.type === RPCType.HTTP) + rpc.route = route; + const span = trace.getActiveSpan(); + span?.updateName(`${method} ${route}`); + if (requestId) + span?.setAttribute(`${logScope()}.request_id`, requestId); +} diff --git a/dist/index.d.ts b/dist/index.d.ts new file mode 100644 index 0000000..94db880 --- /dev/null +++ b/dist/index.d.ts @@ -0,0 +1,10 @@ +export { startTelemetry, shutdownTelemetry, isTelemetryStarted } from "./sdk.js"; +export type { TelemetryConfig, TelemetryEnv } from "./config.js"; +export type { ErrorHookContext, MetricViewOptions, TelemetryOptions } from "./options.js"; +export { setHttpRoute } from "./http-route.js"; +export { getMeter, type BatchObservableResult, type Meter } from "./meter.js"; +export { log, configureLogger, flushLog } from "./shared-log.js"; +export { captureConsole } from "./console-capture.js"; +export { installFatalHandlers } from "./fatal.js"; +export { boundedLabel } from "./bounded.js"; +export { currentTraceparent, withLinkedSpan, withRootSpan } from "./trace-context.js"; diff --git a/dist/index.js b/dist/index.js new file mode 100644 index 0000000..9e051de --- /dev/null +++ b/dist/index.js @@ -0,0 +1,8 @@ +export { startTelemetry, shutdownTelemetry, isTelemetryStarted } from "./sdk.js"; +export { setHttpRoute } from "./http-route.js"; +export { getMeter } from "./meter.js"; +export { log, configureLogger, flushLog } from "./shared-log.js"; +export { captureConsole } from "./console-capture.js"; +export { installFatalHandlers } from "./fatal.js"; +export { boundedLabel } from "./bounded.js"; +export { currentTraceparent, withLinkedSpan, withRootSpan } from "./trace-context.js"; diff --git a/dist/instrumentations.d.ts b/dist/instrumentations.d.ts new file mode 100644 index 0000000..5b3122a --- /dev/null +++ b/dist/instrumentations.d.ts @@ -0,0 +1,6 @@ +import type { Instrumentation } from "@opentelemetry/instrumentation"; +/** + * Hand-picked list: the auto-instrumentations bundle adds fs, dns and net spans that are noise. + * The app's own instrumentations (Prisma, at the app's version) come last. + */ +export declare function buildInstrumentations(ignoredPaths: readonly string[], appInstrumentations?: readonly Instrumentation[]): Instrumentation[]; diff --git a/dist/instrumentations.js b/dist/instrumentations.js new file mode 100644 index 0000000..66ff8be --- /dev/null +++ b/dist/instrumentations.js @@ -0,0 +1,29 @@ +import { HttpInstrumentation } from "@opentelemetry/instrumentation-http"; +import { UndiciInstrumentation } from "@opentelemetry/instrumentation-undici"; +import { IORedisInstrumentation } from "@opentelemetry/instrumentation-ioredis"; +import { PgInstrumentation } from "@opentelemetry/instrumentation-pg"; +import { RuntimeNodeInstrumentation } from "@opentelemetry/instrumentation-runtime-node"; +/** An ignored path covers itself and everything below it, never a sibling sharing a prefix. */ +function isIgnored(url, ignoredPaths) { + const path = (url ?? "").split("?")[0]; + return ignoredPaths.some((ignored) => path === ignored || path.startsWith(`${ignored}/`)); +} +/** + * Hand-picked list: the auto-instrumentations bundle adds fs, dns and net spans that are noise. + * The app's own instrumentations (Prisma, at the app's version) come last. + */ +export function buildInstrumentations(ignoredPaths, appInstrumentations = []) { + return [ + // Event-loop delay, heap, GC. Its uncaught-exception capture stays off: it would emit the raw, + // unscrubbed exception message and stack as a log record. + new RuntimeNodeInstrumentation({ captureUncaughtException: false }), + new HttpInstrumentation({ + ignoreIncomingRequestHook: (request) => isIgnored(request.url, ignoredPaths), + }), + new UndiciInstrumentation(), + new IORedisInstrumentation({ requireParentSpan: true }), + // SQL text only, never bound values. + new PgInstrumentation({ enhancedDatabaseReporting: false, requireParentSpan: true }), + ...appInstrumentations, + ]; +} diff --git a/dist/log-errors.d.ts b/dist/log-errors.d.ts new file mode 100644 index 0000000..f7feea0 --- /dev/null +++ b/dist/log-errors.d.ts @@ -0,0 +1,7 @@ +/** + * The message safe to log for an error: code-only for value-embedding families, else scrubbed. + * An empty message falls back to the error type. + */ +export declare function safeErrorMessage(value: unknown): string; +/** An allowlisted, scrubbed plain copy of an error or error-like value. May throw; see serializeError. */ +export declare function serializeErrorBase(value: unknown): unknown; diff --git a/dist/log-errors.js b/dist/log-errors.js new file mode 100644 index 0000000..e768f99 --- /dev/null +++ b/dist/log-errors.js @@ -0,0 +1,188 @@ +import { scrubErrorText, scrubStack } from "./log-scrub.js"; +const MAX_CAUSE_DEPTH = 10; +const MAX_AGGREGATE_ERRORS = 20; +/** The only properties any error keeps, besides type, name, message, stack, cause and errors. */ +const COMMON_KEYS = ["code", "errorCode", "status", "statusCode"]; +function read(target, key) { + try { + return target[key]; + } + catch { + return undefined; + } +} +function asRecord(value) { + return typeof value === "object" && value !== null ? value : undefined; +} +function str(value) { + return typeof value === "string" ? value : undefined; +} +const PRISMA = { + keys: [], + message: (err, name) => { + const code = str(read(err, "code")) ?? str(read(err, "errorCode")); + return code ? `${name} ${code}` : name; + }, +}; +const DRIVER_ADAPTER = { + keys: [], + dropCause: true, + message: (err, name) => { + const cause = read(err, "cause"); + const code = str(read(cause, "originalCode")) ?? str(read(cause, "kind")); + return code ? `${name} ${code}` : name; + }, +}; +const ZOD = { + keys: [], + message: (err, name) => { + const issues = read(err, "issues"); + return `${name} ${Array.isArray(issues) ? issues.length : 0} issue(s)`; + }, +}; +const STRIPE = { keys: ["decline_code", "param", "requestId"] }; +const HAPPYCO = { keys: ["outcome"] }; +const FUNNEL = { keys: ["api", "errorType"] }; +const GENERIC = { keys: [] }; +function familyOf(name, err) { + if (name.startsWith("Prisma")) + return PRISMA; + if (name === "DriverAdapterError") + return DRIVER_ADAPTER; + if (name === "ZodError") + return ZOD; + if (name.startsWith("Stripe") || str(read(err, "type"))?.startsWith("Stripe")) + return STRIPE; + if (name.startsWith("HappyCo")) + return HAPPYCO; + if (name.startsWith("Funnel")) + return FUNNEL; + return GENERIC; +} +function typeOf(err, name) { + if (err instanceof Error) { + const ctor = read(err, "constructor"); + const ctorName = typeof ctor === "function" ? ctor.name : undefined; + if (ctorName) + return ctorName; + } + return name ?? "Error"; +} +function primitive(value) { + if (typeof value === "string") + return scrubErrorText(value); + return typeof value === "number" || typeof value === "boolean" ? value : undefined; +} +/** Prisma's meta names the failing column or model; every other meta field is a value. */ +function prismaMeta(err) { + const meta = asRecord(read(err, "meta")); + if (!meta) + return undefined; + const target = read(meta, "target"); + const modelName = read(meta, "modelName"); + const out = {}; + // Column names only: an array keeps its strings, so no caller object reaches the serializer. + if (typeof target === "string") + out.target = target; + else if (Array.isArray(target)) + out.target = target.filter((item) => typeof item === "string"); + if (typeof modelName === "string") + out.modelName = modelName; + return out; +} +function sanitizeValue(value, depth, ancestors) { + const record = asRecord(value); + if (!record) + return primitive(value); + if (ancestors.has(record)) + return "[circular]"; + if (depth >= MAX_CAUSE_DEPTH) + return "[truncated]"; + return sanitize(record, depth, ancestors); +} +/** A caller-built error-like object's name is free text; scrub it like a message. */ +function nameOf(err) { + const raw = str(read(err, "name")); + return { raw, name: raw === undefined ? undefined : scrubErrorText(raw) }; +} +/** Deny by default: an error keeps only the fields listed here; every other property is dropped. */ +function sanitize(err, depth, ancestors) { + ancestors.add(err); + try { + return sanitizeFields(err, depth, ancestors); + } + finally { + ancestors.delete(err); + } +} +function sanitizeFields(err, depth, ancestors) { + const { raw, name } = nameOf(err); + const family = familyOf(raw ?? "", err); + const out = { type: typeOf(err, name) }; + if (name !== undefined) + out.name = name; + const safe = family.message?.(err, name ?? "Error"); + const rawMessage = str(read(err, "message")); + if (safe !== undefined) + out.message = safe; + else if (rawMessage !== undefined) + out.message = scrubErrorText(rawMessage); + const stack = str(read(err, "stack")); + if (stack !== undefined) { + // A message that is not a string is rendered into the stack header by the runtime, through + // its own toString; the header then names the type only. + const header = safe !== undefined + ? `${name ?? "Error"}: ${safe}` + : rawMessage === undefined && read(err, "message") !== undefined + ? out.type + : undefined; + out.stack = scrubStack(stack, header); + } + for (const key of [...COMMON_KEYS, ...family.keys]) { + const value = primitive(read(err, key)); + if (value !== undefined) + out[key] = value; + } + if (family === PRISMA) { + const meta = prismaMeta(err); + if (meta) + out.meta = meta; + } + const cause = family.dropCause ? undefined : read(err, "cause"); + if (cause !== undefined) { + const sanitized = sanitizeValue(cause, depth + 1, ancestors); + if (sanitized !== undefined) + out.cause = sanitized; + } + const errors = read(err, "errors"); + if (Array.isArray(errors)) { + out.errors = errors + .slice(0, MAX_AGGREGATE_ERRORS) + .map((item) => sanitizeValue(item, depth + 1, ancestors) ?? null); + } + return out; +} +/** + * The message safe to log for an error: code-only for value-embedding families, else scrubbed. + * An empty message falls back to the error type. + */ +export function safeErrorMessage(value) { + try { + const err = asRecord(value); + if (!err) + return scrubErrorText(String(value)); + const { raw, name } = nameOf(err); + const safe = familyOf(raw ?? "", err).message?.(err, name ?? "Error"); + // A string message only: String() would run a caller object's toString or toPrimitive. + const message = safe ?? scrubErrorText(str(read(err, "message")) ?? ""); + return message === "" ? typeOf(err, name) : message; + } + catch { + return "[unserializable error]"; + } +} +/** An allowlisted, scrubbed plain copy of an error or error-like value. May throw; see serializeError. */ +export function serializeErrorBase(value) { + const err = asRecord(value); + return err ? sanitize(err, 0, new WeakSet()) : primitive(value); +} diff --git a/dist/log-fields.d.ts b/dist/log-fields.d.ts new file mode 100644 index 0000000..ecbe90f --- /dev/null +++ b/dist/log-fields.d.ts @@ -0,0 +1,5 @@ +/** Trace correlation fields for the active span; empty outside a valid span. */ +export declare function traceLogFields(): { + trace_id?: string; + span_id?: string; +}; diff --git a/dist/log-fields.js b/dist/log-fields.js new file mode 100644 index 0000000..de421d2 --- /dev/null +++ b/dist/log-fields.js @@ -0,0 +1,8 @@ +import { isSpanContextValid, trace } from "@opentelemetry/api"; +/** Trace correlation fields for the active span; empty outside a valid span. */ +export function traceLogFields() { + const spanContext = trace.getActiveSpan()?.spanContext(); + if (!spanContext || !isSpanContextValid(spanContext)) + return {}; + return { trace_id: spanContext.traceId, span_id: spanContext.spanId }; +} diff --git a/dist/log-message.d.ts b/dist/log-message.d.ts new file mode 100644 index 0000000..fbeb9b9 --- /dev/null +++ b/dist/log-message.d.ts @@ -0,0 +1,6 @@ +/** + * Arguments for a pino log call with a sanitized `msg`: the raw error message never becomes `msg`, + * a `msg` field is scrubbed like a message string, and every format argument is sanitized exactly + * like the log object. Arguments that cannot be inspected become a placeholder line. + */ +export declare function sanitizeLogArgs(args: unknown[]): unknown[]; diff --git a/dist/log-message.js b/dist/log-message.js new file mode 100644 index 0000000..dca30bc --- /dev/null +++ b/dist/log-message.js @@ -0,0 +1,57 @@ +import { safeErrorMessage } from "./log-errors.js"; +import { plainFields, sanitizeLogValue } from "./log-redact.js"; +import { scrubMessage } from "./log-scrub.js"; +const UNLOGGABLE = "[unloggable]"; +/** pino derives `msg` from an Error first argument or from `err.message` of any object. */ +function errorIn(first) { + if (first instanceof Error) + return first; + if (typeof first !== "object" || first === null) + return undefined; + const fields = first; + for (const key of ["err", "error", "e"]) { + const candidate = fields[key]; + if (candidate instanceof Error) + return candidate; + } + const err = fields.err; + return typeof err === "object" && err !== null && "message" in err ? err : undefined; +} +/** + * Arguments for a pino log call with a sanitized `msg`: the raw error message never becomes `msg`, + * a `msg` field is scrubbed like a message string, and every format argument is sanitized exactly + * like the log object. Arguments that cannot be inspected become a placeholder line. + */ +export function sanitizeLogArgs(args) { + try { + const out = [...args]; + // Converted once, before any msg handling, so a toJSON-hidden field never reaches a spread. + if (typeof out[0] === "object" && out[0] !== null && !(out[0] instanceof Error)) { + out[0] = plainFields(out[0]); + } + const first = out[0]; + const msgIndex = typeof first === "string" ? 0 : 1; + const fields = first; + if (msgIndex === 1 && !(first instanceof Error) && typeof fields?.msg === "string") { + out[0] = { ...fields, msg: scrubMessage(fields.msg) }; + } + if (typeof out[msgIndex] !== "string" && msgIndex === 1) { + const err = errorIn(first); + if (err !== undefined) + out.splice(1, 0, safeErrorMessage(err)); + else if (typeof fields?.msg === "string") { + const { msg, ...rest } = out[0]; + out[0] = rest; + out.splice(1, 0, msg); + } + } + // The message and every format argument take the log object's own sanitizer. + for (let i = msgIndex; i < out.length; i++) + out[i] = sanitizeLogValue(out[i]); + return out; + } + catch { + // Nothing raw goes out: a format arg could carry PII the sanitizer never saw. + return [{ log_redaction_failed: true }, UNLOGGABLE]; + } +} diff --git a/dist/log-otlp.d.ts b/dist/log-otlp.d.ts new file mode 100644 index 0000000..f097c01 --- /dev/null +++ b/dist/log-otlp.d.ts @@ -0,0 +1,27 @@ +import { type Context } from "@opentelemetry/api"; +import { type Logger } from "@opentelemetry/api-logs"; +import type { LogRecordProcessor, SdkLogRecord } from "@opentelemetry/sdk-logs"; +import type { LogLineSink } from "./log-sink.js"; +/** Body of a line with no message, so a text search in Loki still has something to match. */ +export declare const NO_MESSAGE = "(no message)"; +/** + * A sink that turns each line the scrubbing logger wrote into an OTel log record: `msg` (else + * `event`) is the body, `level` the severity, `time` the timestamp, `trace_id`/`span_id` the record + * context, every other field an attribute. Only the serialized line is read, so a record holds + * nothing the line does not. The record is emitted after the line is written, so an SDK diagnostic + * about it (a dropped attribute) follows the line on stdout instead of preceding it. + */ +export declare function logLineSink(logger: Logger): LogLineSink; +/** + * Forwards only records from `scope`, the one fed by the scrubbing logger. Any other emitter on + * the global provider (an instrumentation's raw exception record) is never exported. + */ +export declare class ScopedLogRecordProcessor implements LogRecordProcessor { + private readonly inner; + private readonly scope; + constructor(inner: LogRecordProcessor, scope: string); + onEmit(record: SdkLogRecord, context?: Context): void; + enabled(options: Parameters>[0]): boolean; + forceFlush(): Promise; + shutdown(): Promise; +} diff --git a/dist/log-otlp.js b/dist/log-otlp.js new file mode 100644 index 0000000..6a4fd0f --- /dev/null +++ b/dist/log-otlp.js @@ -0,0 +1,120 @@ +import { isSpanContextValid, ROOT_CONTEXT, trace, TraceFlags, } from "@opentelemetry/api"; +import { SeverityNumber, } from "@opentelemetry/api-logs"; +const SEVERITY = { + trace: SeverityNumber.TRACE, + debug: SeverityNumber.DEBUG, + info: SeverityNumber.INFO, + warn: SeverityNumber.WARN, + error: SeverityNumber.ERROR, + fatal: SeverityNumber.FATAL, +}; +/** Body of a line with no message, so a text search in Loki still has something to match. */ +export const NO_MESSAGE = "(no message)"; +/** + * OTel's own diagnostics (exporter failures among them). Exporting one would feed a failing + * exporter its own failures, once per batch, so they stay on stdout. + */ +function isOtelDiag(fields) { + return fields.component === "telemetry" && fields.event === "otel_diag"; +} +/** The line's correlation fields as a log record context; flags come from the matching span. */ +function lineContext(traceId, spanId) { + if (typeof traceId !== "string" || typeof spanId !== "string") + return undefined; + const active = trace.getActiveSpan()?.spanContext(); + const traceFlags = active?.traceId === traceId && active.spanId === spanId ? active.traceFlags : TraceFlags.NONE; + const spanContext = { traceId, spanId, traceFlags }; + return isSpanContextValid(spanContext) + ? trace.setSpanContext(ROOT_CONTEXT, spanContext) + : undefined; +} +function timestampOf(time) { + if (typeof time !== "string") + return undefined; + const date = new Date(time); + return Number.isNaN(date.getTime()) ? undefined : date; +} +/** + * The fields search and dashboards need, first: the SDK keeps a record's first 128 attributes and + * the logger writes `app` last. + */ +const PRIORITY_FIELDS = ["app", "env", "component", "event"]; +function priorityFirst(fields) { + const out = {}; + for (const key of PRIORITY_FIELDS) + if (key in fields) + out[key] = fields[key]; + for (const [key, value] of Object.entries(fields)) + if (!(key in out)) + out[key] = value; + return out; +} +function bodyOf(msg, event) { + if (typeof msg === "string" && msg !== "") + return msg; + if (typeof event === "string" && event !== "") + return event; + return NO_MESSAGE; +} +/** + * A sink that turns each line the scrubbing logger wrote into an OTel log record: `msg` (else + * `event`) is the body, `level` the severity, `time` the timestamp, `trace_id`/`span_id` the record + * context, every other field an attribute. Only the serialized line is read, so a record holds + * nothing the line does not. The record is emitted after the line is written, so an SDK diagnostic + * about it (a dropped attribute) follows the line on stdout instead of preceding it. + */ +export function logLineSink(logger) { + return (line) => { + const parsed = JSON.parse(line); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) + return; + const { msg, level, time, trace_id, span_id, ...attributes } = parsed; + if (isOtelDiag(attributes)) + return; + const record = { + body: bodyOf(msg, attributes.event), + attributes: priorityFirst(attributes), + context: lineContext(trace_id, span_id) ?? ROOT_CONTEXT, + }; + if (typeof level === "string") { + record.severityText = level; + record.severityNumber = SEVERITY[level] ?? SeverityNumber.UNSPECIFIED; + } + const timestamp = timestampOf(time); + if (timestamp) + record.timestamp = timestamp; + queueMicrotask(() => { + try { + logger.emit(record); + } + catch { + // the line is already on stdout + } + }); + }; +} +/** + * Forwards only records from `scope`, the one fed by the scrubbing logger. Any other emitter on + * the global provider (an instrumentation's raw exception record) is never exported. + */ +export class ScopedLogRecordProcessor { + inner; + scope; + constructor(inner, scope) { + this.inner = inner; + this.scope = scope; + } + onEmit(record, context) { + if (record.instrumentationScope.name === this.scope) + this.inner.onEmit(record, context); + } + enabled(options) { + return options.instrumentationScope.name === this.scope; + } + forceFlush() { + return this.inner.forceFlush(); + } + shutdown() { + return this.inner.shutdown(); + } +} diff --git a/dist/log-redact.d.ts b/dist/log-redact.d.ts new file mode 100644 index 0000000..c907146 --- /dev/null +++ b/dist/log-redact.d.ts @@ -0,0 +1,29 @@ +/** Pinned by scrub-contract.test.ts: the agent's layer-2 log scrub repeats these patterns. */ +export declare const LOG_REDACT_PATTERNS: Readonly>; +export { resetAppKeys as resetSensitiveKeys } from "./app-keys.js"; +export declare function isSensitiveKey(key: string): boolean; +/** + * The JSON-facing form of an object: URL, byte view, date and toJSON values converted; `done` + * stops the walk. A done value is inert: a plain string or object, so no serialization hook of + * the caller's (an own toJSON on a Map, a Date subclass) runs after the sanitizer. A Map, Set or + * RegExp becomes `{}`, as JSON shows one without own properties. + */ +export declare function convert(value: object): { + done: boolean; + value: unknown; +}; +/** + * A log call's first argument or child bindings as JSON would see them, so a spread keeps what a + * toJSON hides hidden. Byte views become `{ bytes }`; a value that is not a plain record (an + * array, a date, a map's entries, what a toJSON returns) lands under `value`. + */ +export declare function plainFields(fields: object, nested?: boolean): Record; +/** + * A sanitized copy of a log value; the caller's value is never mutated. Fails closed. The log + * object, child bindings and every format argument take this one path: PII keys are redacted at + * any depth, every Error and every non-nullish value under err/error/e is serialized deny-by- + * default, and every string gets the message scrub (`scrubMessage`). + */ +export declare function sanitizeLogValue(value: unknown): unknown; +/** The log object or bindings, through sanitizeLogValue. */ +export declare function redactLogObject(obj: Record): Record; diff --git a/dist/log-redact.js b/dist/log-redact.js new file mode 100644 index 0000000..664655e --- /dev/null +++ b/dist/log-redact.js @@ -0,0 +1,221 @@ +import { serializeError } from "./log-serializer.js"; +import { scrubMessage } from "./log-scrub.js"; +import { stripQuery } from "./redact.js"; +import { describePatterns } from "./scrub-descriptor.js"; +import { isAppKey } from "./app-keys.js"; +// Matches PII and credential key names (camelCase, snake_case, kebab-case) at any depth. +// `ssn`, `dob` and `ein` are anchored so words such as "businessName", "adobe" or "protein" +// stay readable; a camelCase `...Ein` is matched case-sensitively in isSensitiveKey. +// A person's name is matched by its qualifier (`firstName`, `contactName`, `payeeName`). A bare +// `name` is not: queue, job, category and target names are logged under it. +// Any key ending in `address` is: in a provider payload it is a mailing address. That includes +// `ipAddress`, which only the access-audit rows store and no log call site emits. +// Structured address parts (`street1`, `line1`) and `mobile` are matched too. +const SENSITIVE_KEY_PATTERN = /(^ssn|ssn$|ssn.?(enc|last)|_ssn|social.?security|^dob|dob$|dob.?enc|_dob|date.?of.?birth|birth.?date|account.?number|routing.?number|tax.?id|taxpayer|(^|_)ein(_?number)?$|e.?mail|phone|password|passwd|secret|token|api.?key|authorization|cookie|holder.?name|iban|passport|drivers?.?license|birthday|bank.?account|aba.?number|clerk.?auth|signature$|enc$|enc.?snapshot$|(first|last|middle|full|legal|preferred|given|family).?name|surname|(occupant|caller|contact|payee|bill.?to|tenant|resident|applicant|guarantor|performed.?by|assignee|recipient|signer).?name|(street|mailing|home|billing|residential|postal).?address|^address(.?line)?.?\d*$|address.?line|address$|forwarding.?address|^street\d*$|^line[12]$|mobile|name.?on.?account|postal.?code|^zip|zip.?code)/i; +const CAMEL_EIN = /[a-z](Ein|EIN)(Number)?$/; +// A record id (`emailId`, `bankAccountId`, `resendEmailId`) names a row, not the value it points +// at. +const RECORD_ID = /(Id|ID|Ids|IDs|_id|_ids)$/; +// ...except where the id is itself the identifier of a person. +const PERSON_ID = /(tax|taxpayer|national|government|passport|license|licence|ssn).?(id|ids)$/i; +/** Pinned by scrub-contract.test.ts: the agent's layer-2 log scrub repeats these patterns. */ +export const LOG_REDACT_PATTERNS = describePatterns({ + SENSITIVE_KEY_PATTERN, + CAMEL_EIN, + PERSON_ID, + RECORD_ID, +}); +export { resetAppKeys as resetSensitiveKeys } from "./app-keys.js"; +export function isSensitiveKey(key) { + if (isAppKey(key)) + return true; + if (PERSON_ID.test(key)) + return true; + if (RECORD_ID.test(key)) + return false; + return SENSITIVE_KEY_PATTERN.test(key) || CAMEL_EIN.test(key); +} +const MAX_DEPTH = 8; +const MAX_BREADTH = 1_000; +/** Total values visited per log line, so a deep and wide object cannot stall the process. */ +const MAX_NODES = 5_000; +const REDACTED = "[redacted]"; +const TRUNCATED = "[truncated]"; +const ERROR_KEYS = new Set(["err", "error", "e"]); +function isOpaque(value) { + return (value instanceof Map || + value instanceof Set || + value instanceof RegExp || + value instanceof ArrayBuffer); +} +/** A date as JSON shows it, read with the built-in method so a subclass cannot change it. */ +function isoDate(value) { + return Number.isNaN(Date.prototype.getTime.call(value)) + ? null + : Date.prototype.toISOString.call(value); +} +/** + * The JSON-facing form of an object: URL, byte view, date and toJSON values converted; `done` + * stops the walk. A done value is inert: a plain string or object, so no serialization hook of + * the caller's (an own toJSON on a Map, a Date subclass) runs after the sanitizer. A Map, Set or + * RegExp becomes `{}`, as JSON shows one without own properties. + */ +export function convert(value) { + if (value instanceof URL) + return { done: true, value: scrubMessage(stripQuery(value.href)) }; + if (ArrayBuffer.isView(value)) + return { done: true, value: { bytes: value.byteLength } }; + if (value instanceof Date) + return { done: true, value: isoDate(value) }; + if (isOpaque(value)) + return { done: true, value: {} }; + const toJSON = value.toJSON; + if (typeof toJSON === "function") + return { done: false, value: toJSON.call(value) }; + return { done: false, value }; +} +/** + * A log call's first argument or child bindings as JSON would see them, so a spread keeps what a + * toJSON hides hidden. Byte views become `{ bytes }`; a value that is not a plain record (an + * array, a date, a map's entries, what a toJSON returns) lands under `value`. + */ +export function plainFields(fields, nested = false) { + if (ArrayBuffer.isView(fields)) + return { bytes: fields.byteLength }; + if (Array.isArray(fields)) + return { value: fields }; + if (fields instanceof Date) + return { value: isoDate(fields) }; + if (fields instanceof Map) + return { value: Object.fromEntries(fields) }; + const { value } = convert(fields); + if (value === fields) + return fields; + // What a toJSON returns gets the same treatment once, so a returned date or map keeps its value. + if (typeof value === "object" && value !== null) { + return nested ? value : plainFields(value, true); + } + return { value }; +} +/** Maps an array within the breadth cap, stopping at the first item past the node budget. */ +function walkItems(items, depth, state, visit) { + const out = []; + for (const item of items) { + if (out.length >= MAX_BREADTH || state.nodes >= MAX_NODES) { + out.push(TRUNCATED); + break; + } + out.push(visit(item, depth + 1, state)); + } + return out; +} +/** A value under err/error/e at any depth: serialized deny-by-default; an array is mapped. */ +function walkErrorValue(value, depth, state) { + if (++state.nodes > MAX_NODES) + return TRUNCATED; + if (value === null || value === undefined) + return value; + if (!Array.isArray(value)) + return serializeError(value); + if (depth > MAX_DEPTH) + return TRUNCATED; + return walkItems(value, depth, state, walkErrorValue); +} +/** `base`, or `base#n` when that name is taken; the chosen name is then taken. */ +function freeKey(base, taken) { + let candidate = base; + for (let n = 1; taken.has(candidate); n++) + candidate = `${base}#${n}`; + taken.add(candidate); + return candidate; +} +/** + * An object's entries within the breadth cap, each with the key it is logged under. A key that the + * message scrub would change becomes `[redacted]`; every clean key is reserved first, so a caller key that + * looks like a marker is never overwritten and never overwrites. + */ +function keyedEntries(target) { + const raw = Object.entries(target).slice(0, MAX_BREADTH + 1); + const clean = raw.map(([k]) => scrubMessage(k) === k); + const taken = new Set(raw.filter((_, i) => clean[i]).map(([k]) => k)); + const entries = raw.map(([k, v], i) => [ + k, + clean[i] ? k : freeKey(REDACTED, taken), + v, + ]); + return { entries, taken }; +} +function walk(value, depth, state) { + if (++state.nodes > MAX_NODES) + return TRUNCATED; + if (typeof value === "string") + return scrubMessage(value); + // JSON drops a function; returning it would let its own toJSON run after the sanitizer. + if (typeof value === "function") + return undefined; + if (typeof value !== "object" || value === null) + return value; + if (value instanceof Error) + return serializeError(value); + const converted = convert(value); + if (converted.done) + return converted.value; + const target = converted.value; + // What a toJSON returns is walked like any value: a returned date, map or toJSON is reduced too. + // A primitive result (a Decimal's string) costs no depth. + if (target !== value) { + if (typeof target !== "object" || target === null) + return walk(target, depth, state); + return depth > MAX_DEPTH ? TRUNCATED : walk(target, depth + 1, state); + } + if (state.ancestors.has(target)) + return "[circular]"; + if (depth > MAX_DEPTH) + return TRUNCATED; + state.ancestors.add(target); + try { + if (Array.isArray(target)) + return walkItems(target, depth, state, walk); + const out = {}; + const { entries, taken } = keyedEntries(target); + let count = 0; + for (const [k, key, child] of entries) { + if (++count > MAX_BREADTH || state.nodes >= MAX_NODES) { + out[freeKey(TRUNCATED, taken)] = true; + break; + } + if (isSensitiveKey(k)) + out[key] = REDACTED; + else if (ERROR_KEYS.has(k)) + out[key] = walkErrorValue(child, depth + 1, state); + else { + // A dropped value (a function) leaves no key, as in JSON, so it cannot shadow a method. + const walked = walk(child, depth + 1, state); + if (walked !== undefined) + out[key] = walked; + } + } + return out; + } + finally { + state.ancestors.delete(target); + } +} +/** + * A sanitized copy of a log value; the caller's value is never mutated. Fails closed. The log + * object, child bindings and every format argument take this one path: PII keys are redacted at + * any depth, every Error and every non-nullish value under err/error/e is serialized deny-by- + * default, and every string gets the message scrub (`scrubMessage`). + */ +export function sanitizeLogValue(value) { + try { + return walk(value, 0, { nodes: 0, ancestors: new WeakSet() }); + } + catch { + return { log_redaction_failed: true }; + } +} +/** The log object or bindings, through sanitizeLogValue. */ +export function redactLogObject(obj) { + return sanitizeLogValue(obj); +} diff --git a/dist/log-safely.d.ts b/dist/log-safely.d.ts new file mode 100644 index 0000000..874d964 --- /dev/null +++ b/dist/log-safely.d.ts @@ -0,0 +1,7 @@ +import type { DiagLogger } from "@opentelemetry/api"; +export declare const log: import("pino").Logger; +type Level = "trace" | "debug" | "info" | "warn" | "error"; +/** Telemetry never breaks the app, so not even a failing logger may throw out of it. */ +export declare function logSafely(level: Level, fields: Record, message: string): void; +export declare const diagLogger: DiagLogger; +export {}; diff --git a/dist/log-safely.js b/dist/log-safely.js new file mode 100644 index 0000000..b7201a4 --- /dev/null +++ b/dist/log-safely.js @@ -0,0 +1,25 @@ +import { log as sharedLog } from "./shared-log.js"; +export const log = sharedLog.child({ component: "telemetry" }); +/** Telemetry never breaks the app, so not even a failing logger may throw out of it. */ +export function logSafely(level, fields, message) { + try { + log[level](fields, message); + } + catch { + // nothing left to report to + } +} +/** OTel's own diagnostics, through the redacting logger; only an Error argument is kept. */ +function diagMethod(level, event) { + return (message, ...args) => { + const err = args.find((arg) => arg instanceof Error); + logSafely(level, { event, ...(err !== undefined && { err }) }, message); + }; +} +export const diagLogger = { + error: diagMethod("error", "otel_diag"), + warn: diagMethod("warn", "otel_diag"), + info: diagMethod("info", "otel_diag"), + debug: diagMethod("debug", "otel_diag"), + verbose: diagMethod("trace", "otel_diag"), +}; diff --git a/dist/log-scrub.d.ts b/dist/log-scrub.d.ts new file mode 100644 index 0000000..abc7d87 --- /dev/null +++ b/dist/log-scrub.d.ts @@ -0,0 +1,12 @@ +/** Pinned by scrub-contract.test.ts: the agent's layer-2 scrub repeats these patterns. */ +export declare const LOG_SCRUB_PATTERNS: Readonly>; +/** + * Replaces SSN and email shapes in free text, strips the userinfo, query and fragment from any URL + * it quotes and the query and fragment from an absolute path. Phone and account shapes are left + * alone (id false positives). + */ +export declare function scrubMessage(text: string): string; +/** Error text comes from libraries and providers, so standalone long digit runs go too. */ +export declare function scrubErrorText(text: string): string; +/** Scrubs the header line(s) of a stack and keeps every `at` frame byte for byte. */ +export declare function scrubStack(stack: string, header?: string): string; diff --git a/dist/log-scrub.js b/dist/log-scrub.js new file mode 100644 index 0000000..ddffbbc --- /dev/null +++ b/dist/log-scrub.js @@ -0,0 +1,76 @@ +import { describePatterns } from "./scrub-descriptor.js"; +import { HOST_PORT, QUERY_START, QUOTE_END, scrubUrls, USERINFO_QUOTE, URL_END, URL_START, URL_TOKEN, USERINFO_END, } from "./url-credentials.js"; +// Every pattern here can start a match only at a bounded set of positions (an `@`, the start of a +// run, a newline), so each scrub is linear in the input length. A pattern that may start anywhere +// in a long run rescans the run from every position and blocks the event loop for seconds. URL +// userinfo and query removal live in url-credentials.ts. +const SSN_SHAPE = /\b\d{3}-\d{2}-\d{4}\b/g; +// An email's domain, matched at its `@`. The TLD must be alphabetic, so a pnpm store path +// (`.pnpm/hono+node-server@1.19.5/...`) is not an email. +const EMAIL_DOMAIN = /@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}\b/g; +const EMAIL_LOCAL_CHAR = /[A-Za-z0-9._%+-]/; +// Eight or more digits standing alone: an account or card number. A UUID group or a prefixed +// provider id (`pi_12345678`) touches a letter, `_` or `-`, so it is not matched. +const LONG_DIGIT_RUN = /(? last && EMAIL_LOCAL_CHAR.test(text[start - 1])) + start--; + if (start === m.index) { + EMAIL_DOMAIN.lastIndex = m.index + 1; + continue; + } + out += text.slice(last, start) + REDACTED; + last = m.index + m[0].length; + } + return out + text.slice(last); +} +/** + * Replaces SSN and email shapes in free text, strips the userinfo, query and fragment from any URL + * it quotes and the query and fragment from an absolute path. Phone and account shapes are left + * alone (id false positives). + */ +export function scrubMessage(text) { + return replaceEmails(scrubUrls(text).replace(PATH_IN_TEXT, "$1$2").replace(SSN_SHAPE, REDACTED)); +} +/** Error text comes from libraries and providers, so standalone long digit runs go too. */ +export function scrubErrorText(text) { + return scrubMessage(text).replace(LONG_DIGIT_RUN, REDACTED); +} +/** Scrubs the header line(s) of a stack and keeps every `at` frame byte for byte. */ +export function scrubStack(stack, header) { + const match = FRAME_START.exec(stack); + const at = match ? match.index + match[0].indexOf("\n") : -1; + const frames = at === -1 ? "" : stack.slice(at); + const head = header ?? (at === -1 ? stack : stack.slice(0, at)); + return `${scrubErrorText(head)}${frames}`; +} diff --git a/dist/log-serializer.d.ts b/dist/log-serializer.d.ts new file mode 100644 index 0000000..15a1183 --- /dev/null +++ b/dist/log-serializer.d.ts @@ -0,0 +1,2 @@ +/** The `err` serializer: an allowlisted, scrubbed copy of the error chain. Never throws. */ +export declare function serializeError(value: unknown): unknown; diff --git a/dist/log-serializer.js b/dist/log-serializer.js new file mode 100644 index 0000000..24b0c73 --- /dev/null +++ b/dist/log-serializer.js @@ -0,0 +1,20 @@ +import { serializeErrorBase } from "./log-errors.js"; +import { scrubErrorText } from "./log-scrub.js"; +/** The `err` serializer: an allowlisted, scrubbed copy of the error chain. Never throws. */ +export function serializeError(value) { + try { + return serializeErrorBase(value); + } + catch { + let type = "Error"; + try { + const name = value?.name; + if (typeof name === "string") + type = scrubErrorText(name); + } + catch { + // keep the default type + } + return { type, message: "[unserializable error]" }; + } +} diff --git a/dist/log-sink.d.ts b/dist/log-sink.d.ts new file mode 100644 index 0000000..70bab96 --- /dev/null +++ b/dist/log-sink.d.ts @@ -0,0 +1,10 @@ +/** Receives each serialized line the logger writes; installed only once the SDK has booted. */ +export type LogLineSink = (line: string) => void; +/** Installs (or, with undefined, removes) the sink every logger line is copied to. */ +export declare function setLogLineSink(next: LogLineSink | undefined): void; +export declare function hasLogLineSink(): boolean; +/** + * Copies a written line to the sink and returns it unchanged. A sink that throws, or a line logged + * while the sink is running, is dropped: exporting must never break or recurse into a log call. + */ +export declare function forwardLogLine(line: string): string; diff --git a/dist/log-sink.js b/dist/log-sink.js new file mode 100644 index 0000000..5dd4489 --- /dev/null +++ b/dist/log-sink.js @@ -0,0 +1,29 @@ +let sink; +let forwarding = false; +/** Installs (or, with undefined, removes) the sink every logger line is copied to. */ +export function setLogLineSink(next) { + sink = next; +} +export function hasLogLineSink() { + return sink !== undefined; +} +/** + * Copies a written line to the sink and returns it unchanged. A sink that throws, or a line logged + * while the sink is running, is dropped: exporting must never break or recurse into a log call. + */ +export function forwardLogLine(line) { + const current = sink; + if (current === undefined || forwarding) + return line; + forwarding = true; + try { + current(line); + } + catch { + // the line still reaches stdout + } + finally { + forwarding = false; + } + return line; +} diff --git a/dist/logger.d.ts b/dist/logger.d.ts new file mode 100644 index 0000000..65614ef --- /dev/null +++ b/dist/logger.d.ts @@ -0,0 +1,15 @@ +import { type DestinationStream, type Logger } from "pino"; +/** + * LOG_LEVEL (any case) when it names a level; anything else falls back to "info" rather than + * throwing. + */ +export declare function resolveLogLevel(raw?: string | undefined): { + level: string; + invalid?: string; +}; +export declare function announceLogLevel(logger: Logger): void; +/** + * Builds a redacting pino logger that names its process in `app`. `app` is read on every line, so + * a logger or child created before the process names itself still reports the configured name. + */ +export declare function createLogger(app: () => string, destination?: DestinationStream): Logger; diff --git a/dist/logger.js b/dist/logger.js new file mode 100644 index 0000000..d0df2b3 --- /dev/null +++ b/dist/logger.js @@ -0,0 +1,99 @@ +import { pino, stdTimeFunctions } from "pino"; +import { sanitizeLogArgs } from "./log-message.js"; +import { plainFields, redactLogObject } from "./log-redact.js"; +import { traceLogFields } from "./log-fields.js"; +import { forwardLogLine } from "./log-sink.js"; +// `silent` is not offered: it would hide fatal lines too. +const VALID_LEVELS = new Set(["trace", "debug", "info", "warn", "error", "fatal"]); +/** + * Fields the logger owns. A caller's field of the same name is renamed `field.`, so only the + * active span sets `trace_id`/`span_id` (a Stripe payout carries its own `trace_id`). + */ +const RESERVED_FIELDS = new Set(["level", "time", "msg", "app", "env", "trace_id", "span_id"]); +/** + * LOG_LEVEL (any case) when it names a level; anything else falls back to "info" rather than + * throwing. + */ +export function resolveLogLevel(raw = process.env.LOG_LEVEL) { + const requested = raw?.trim(); + if (!requested) + return { level: "info" }; + const level = requested.toLowerCase(); + return VALID_LEVELS.has(level) ? { level } : { level: "info", invalid: requested }; +} +function renameReserved(fields) { + if (!Object.keys(fields).some((key) => RESERVED_FIELDS.has(key))) + return fields; + const out = {}; + for (const [key, value] of Object.entries(fields)) { + out[RESERVED_FIELDS.has(key) ? `field.${key}` : key] = value; + } + return out; +} +/** Child bindings in their JSON shape, renamed; a binding that cannot be read never throws. */ +function safeBindings(bindings) { + try { + return renameReserved(plainFields(bindings)); + } + catch { + return { log_redaction_failed: true }; + } +} +/** A new object: the caller's fields (reserved names renamed) under the logger's own fields. */ +function mergeMixin(fields, mixin) { + try { + return { ...renameReserved(plainFields(fields)), ...mixin }; + } + catch { + return { log_redaction_failed: true, ...mixin }; + } +} +/** Emits the invalid LOG_LEVEL warning once; held back until the process has named itself. */ +const pendingLevelWarnings = new WeakMap(); +export function announceLogLevel(logger) { + const emit = pendingLevelWarnings.get(logger); + pendingLevelWarnings.delete(logger); + emit?.(); +} +/** + * Builds a redacting pino logger that names its process in `app`. `app` is read on every line, so + * a logger or child created before the process names itself still reports the configured name. + */ +export function createLogger(app, destination) { + const { level, invalid } = resolveLogLevel(); + let logger; + const announce = () => announceLogLevel(logger); + logger = pino({ + level, + base: { env: process.env.RAILWAY_ENVIRONMENT_NAME?.trim() || "unknown" }, + // The log formatter sanitizes the whole object, err/error/e included, on the same path as + // format args. pino's default err serializer would rewrite that result, so it is replaced. + serializers: { err: (value) => value }, + formatters: { + level: (label) => ({ level: label }), + bindings: redactLogObject, + log: redactLogObject, + }, + hooks: { + logMethod(args, method) { + announce(); + return method.apply(this, sanitizeLogArgs(args)); + }, + // The finished, scrubbed line: written unchanged, and copied to the OTLP sink once the SDK + // has installed one. + streamWrite: forwardLogLine, + }, + timestamp: stdTimeFunctions.isoTime, + mixin: () => ({ app: app(), ...traceLogFields() }), + mixinMergeStrategy: (fields, mixin) => mergeMixin(fields, mixin), + }, destination); + // pino applies formatters.bindings to the base bindings only, so child bindings are handled here. + const originalChild = logger.child; + logger.child = function (bindings, options) { + return originalChild.call(this, redactLogObject(safeBindings(bindings)), options); + }; + if (invalid !== undefined) { + pendingLevelWarnings.set(logger, () => logger.warn({ event: "invalid_log_level", requested: invalid }, "invalid LOG_LEVEL, using info")); + } + return logger; +} diff --git a/dist/meter.d.ts b/dist/meter.d.ts new file mode 100644 index 0000000..4f28159 --- /dev/null +++ b/dist/meter.d.ts @@ -0,0 +1,7 @@ +import { type Meter } from "@opentelemetry/api"; +/** + * A meter on the global provider. Obtain it after `startTelemetry`: a meter taken earlier is a + * no-op and never records, even once telemetry starts. + */ +export declare function getMeter(name: string): Meter; +export type { BatchObservableResult, Meter } from "@opentelemetry/api"; diff --git a/dist/meter.js b/dist/meter.js new file mode 100644 index 0000000..5b59ec8 --- /dev/null +++ b/dist/meter.js @@ -0,0 +1,8 @@ +import { metrics } from "@opentelemetry/api"; +/** + * A meter on the global provider. Obtain it after `startTelemetry`: a meter taken earlier is a + * no-op and never records, even once telemetry starts. + */ +export function getMeter(name) { + return metrics.getMeter(name); +} diff --git a/dist/metric-views.d.ts b/dist/metric-views.d.ts new file mode 100644 index 0000000..4875236 --- /dev/null +++ b/dist/metric-views.d.ts @@ -0,0 +1,7 @@ +import { type ViewOptions } from "@opentelemetry/sdk-metrics"; +import type { MetricViewOptions } from "./options.js"; +/** + * One view that drops every attribute outside `allowedAttributes` from the app's own instruments + * (`*`), so an id or a message never becomes a label. No options, no views. + */ +export declare function metricViews(options?: MetricViewOptions): ViewOptions[]; diff --git a/dist/metric-views.js b/dist/metric-views.js new file mode 100644 index 0000000..5cbf499 --- /dev/null +++ b/dist/metric-views.js @@ -0,0 +1,15 @@ +import { createAllowListAttributesProcessor } from "@opentelemetry/sdk-metrics"; +/** + * One view that drops every attribute outside `allowedAttributes` from the app's own instruments + * (`*`), so an id or a message never becomes a label. No options, no views. + */ +export function metricViews(options) { + if (!options) + return []; + return [ + { + instrumentName: `${options.instrumentPrefix}*`, + attributesProcessors: [createAllowListAttributesProcessor([...options.allowedAttributes])], + }, + ]; +} diff --git a/dist/options.d.ts b/dist/options.d.ts new file mode 100644 index 0000000..b00a3bc --- /dev/null +++ b/dist/options.d.ts @@ -0,0 +1,53 @@ +import type { Instrumentation } from "@opentelemetry/instrumentation"; +import type { TelemetryEnv } from "./config.js"; +/** An allowlist view: instruments named `*` export only these attributes. */ +export interface MetricViewOptions { + instrumentPrefix: string; + allowedAttributes: readonly string[]; +} +/** Context passed to `onError`: the service name, plus whatever the caller adds. */ +export type ErrorHookContext = Record; +export interface TelemetryOptions { + serviceName: string; + /** Only records from this logger scope are exported; defaults to `serviceName`. */ + logScope?: string; + /** Without it, every metric attribute is kept. */ + metricViews?: MetricViewOptions; + /** Appended to the package's own list, so Prisma and the like match the app's versions. */ + instrumentations?: Instrumentation[]; + /** Log field names redacted on top of the default PII key list; never removes a default. */ + redactKeys?: readonly string[]; + /** Receives every fatal error `installFatalHandlers` sees, when it is given no `report`. */ + onError?: (err: unknown, context: ErrorHookContext) => void; + /** Read instead of `process.env`. */ + env?: TelemetryEnv; + /** Incoming request paths that open no server span; each covers itself and everything below. */ + ignoredPaths?: readonly string[]; + /** False for a host that runs bundled CommonJS (Next.js): no ESM loader hook is registered. */ + esmLoaderHook?: boolean; + /** False keeps log lines on stdout only; a host whose own code does not use the shared logger. */ + logs?: boolean; +} +/** A checked copy of the caller's options, and the types of the redact keys it dropped. */ +export interface CheckedOptions { + options: TelemetryOptions; + invalidKeyTypes: string[]; +} +/** + * Validates and copies options without touching any state. Throws on options that cannot be read; + * a redact key that is not a string is dropped and only its type is reported. + */ +export declare function checkOptions(input: TelemetryOptions): CheckedOptions; +/** + * Applies checked options in one step: the log scope, the error hook and the redact keys. Nothing + * here can throw, so a call never leaves its options half-applied. + */ +export declare function applyOptions(options: TelemetryOptions): void; +/** The configured log scope; before `startTelemetry`, the service name from the environment. */ +export declare function logScope(): string; +/** Calls the app's error hook, if any, with `service` set; never throws. */ +export declare function reportError(err: unknown, context?: ErrorHookContext): void; +/** True when two calls pass the same options; `env` aside, since it only says whether to export. */ +export declare function sameOptions(a: TelemetryOptions, b: TelemetryOptions): boolean; +/** Test seam: forgets the recorded options. */ +export declare function resetOptions(): void; diff --git a/dist/options.js b/dist/options.js new file mode 100644 index 0000000..b717a54 --- /dev/null +++ b/dist/options.js @@ -0,0 +1,84 @@ +import { addAppKeys } from "./app-keys.js"; +let scope; +let service; +let errorHook; +function check(ok, message) { + if (!ok) + throw new TypeError(message); +} +/** + * Validates and copies options without touching any state. Throws on options that cannot be read; + * a redact key that is not a string is dropped and only its type is reported. + */ +export function checkOptions(input) { + check(typeof input === "object" && input !== null, "options must be an object"); + const options = { ...input }; + check(typeof options.serviceName === "string", "serviceName must be a string"); + check(options.logScope === undefined || typeof options.logScope === "string", "bad logScope"); + check(options.onError === undefined || typeof options.onError === "function", "bad onError"); + check(options.env === undefined || typeof options.env === "object", "bad env"); + check(options.redactKeys === undefined || Array.isArray(options.redactKeys), "bad redactKeys"); + const keys = [...(options.redactKeys ?? [])]; + const invalidKeyTypes = keys + .filter((key) => typeof key !== "string") + .map((key) => (key === null ? "null" : typeof key)); + if (options.redactKeys) { + options.redactKeys = keys.filter((key) => typeof key === "string"); + } + return { options, invalidKeyTypes }; +} +/** + * Applies checked options in one step: the log scope, the error hook and the redact keys. Nothing + * here can throw, so a call never leaves its options half-applied. + */ +export function applyOptions(options) { + scope = options.logScope?.trim() || options.serviceName; + service = options.serviceName; + errorHook = options.onError; + if (options.redactKeys) + addAppKeys(options.redactKeys); +} +/** The configured log scope; before `startTelemetry`, the service name from the environment. */ +export function logScope() { + return scope ?? (process.env.OTEL_SERVICE_NAME?.trim() || "unknown"); +} +/** Calls the app's error hook, if any, with `service` set; never throws. */ +export function reportError(err, context = {}) { + try { + errorHook?.(err, { service: service ?? "unknown", ...context }); + } + catch { + // reporting must never break the caller + } +} +/** Equal arrays: the same items, by identity. */ +function sameItems(a, b) { + if (!Array.isArray(a) || !Array.isArray(b)) + return a === b; + return a.length === b.length && a.every((item, i) => item === b[i]); +} +/** True when two calls pass the same options; `env` aside, since it only says whether to export. */ +export function sameOptions(a, b) { + const keys = new Set([...Object.keys(a), ...Object.keys(b)]); + keys.delete("env"); + for (const key of keys) { + const x = a[key]; + const y = b[key]; + if (key === "metricViews") { + const [m, n] = [x, y]; + if (m?.instrumentPrefix !== n?.instrumentPrefix) + return false; + if (!sameItems(m?.allowedAttributes, n?.allowedAttributes)) + return false; + } + else if (!sameItems(x, y)) + return false; + } + return true; +} +/** Test seam: forgets the recorded options. */ +export function resetOptions() { + scope = undefined; + service = undefined; + errorHook = undefined; +} diff --git a/dist/profiling.d.ts b/dist/profiling.d.ts new file mode 100644 index 0000000..b60baec --- /dev/null +++ b/dist/profiling.d.ts @@ -0,0 +1,10 @@ +import type { TelemetryConfig, TelemetryEnv } from "./config.js"; +/** + * Starts Pyroscope CPU and heap profiling. Gated on `PYROSCOPE_SERVER_ADDRESS` alone, so it + * is independent of the OTLP endpoint, and `OTEL_SDK_DISABLED=true` still switches it off. + * Tags are `env` and `version` only; request-scoped labels would carry identifiers. Never + * rejects: a missing native binary logs `profiling_start_failed` and returns false. + */ +export declare function startProfiling(config: TelemetryConfig, env?: TelemetryEnv): Promise; +/** Stops the profiler; never rejects. */ +export declare function stopProfiling(): Promise; diff --git a/dist/profiling.js b/dist/profiling.js new file mode 100644 index 0000000..a4a5ea3 --- /dev/null +++ b/dist/profiling.js @@ -0,0 +1,51 @@ +import { logSafely } from "./log-safely.js"; +let profiler; +/** + * Starts Pyroscope CPU and heap profiling. Gated on `PYROSCOPE_SERVER_ADDRESS` alone, so it + * is independent of the OTLP endpoint, and `OTEL_SDK_DISABLED=true` still switches it off. + * Tags are `env` and `version` only; request-scoped labels would carry identifiers. Never + * rejects: a missing native binary logs `profiling_start_failed` and returns false. + */ +export async function startProfiling(config, env = process.env) { + try { + if (profiler) + return true; + const serverAddress = env.PYROSCOPE_SERVER_ADDRESS?.trim(); + if (!serverAddress) + return false; + if (env.OTEL_SDK_DISABLED?.trim().toLowerCase() === "true") + return false; + const module = await import("@pyroscope/nodejs"); + const pyroscope = module.default ?? module; + pyroscope.init({ + serverAddress, + appName: config.serviceName, + tags: { env: config.environment, version: config.serviceVersion }, + // Pins the 0.6.4 defaults: 60 s flush, 100 Hz wall sampling, one heap sample per 512 KiB. + flushIntervalMs: 60_000, + wall: { samplingDurationMs: 60_000, samplingIntervalMicros: 10_000 }, + heap: { samplingIntervalBytes: 512 * 1024, stackDepth: 64 }, + }); + pyroscope.start(); + profiler = pyroscope; + return true; + } + catch (error) { + profiler = undefined; + logSafely("warn", { err: error, event: "profiling_start_failed" }, "profiling_start_failed"); + return false; + } +} +/** Stops the profiler; never rejects. */ +export async function stopProfiling() { + const running = profiler; + profiler = undefined; + if (!running) + return; + try { + await running.stop(); + } + catch { + // shutdown proceeds regardless + } +} diff --git a/dist/redact.d.ts b/dist/redact.d.ts new file mode 100644 index 0000000..9df37af --- /dev/null +++ b/dist/redact.d.ts @@ -0,0 +1,24 @@ +import type { Attributes } from "@opentelemetry/api"; +/** `schemeless` reads a value with no scheme and no leading `/` as starting at its authority. */ +export declare function stripQuery(url: string, schemeless?: boolean): string; +/** Pinned by scrub-contract.test.ts: the agent's layer-2 span scrub repeats these. */ +export declare const REDACT_PATTERNS: Readonly>; +export declare const REDACT_KEY_SETS: Readonly>; +export declare const REDACT_LITERALS: Readonly<{ + REDIS_SYSTEM: "redis"; +}>; +/** + * A span name of the form `METHOD /path`, `METHOD scheme://host/path` or `METHOD user@host/path` + * gets the same treatment as a URL attribute: no query, fragment or userinfo, and the path + * templated. Next.js names an unrouted request's span after its raw URL. Any other name gets the + * message scrub, and each of its words its token paths templated. + */ +export declare function scrubSpanName(name: string, route: string | undefined, tokenPaths?: readonly (readonly [RegExp, string])[]): string; +/** + * Scrubs one attribute set. Every URL attribute loses its query, fragment and userinfo, and its + * path becomes the span's `http.route` template when it has one, so a path parameter never + * leaves. Without a route (client spans, unmatched requests) `url.path` is dropped and known + * token-bearing paths are templated in the remaining URL attributes. An attribute named by the + * app's `redactKeys` (its whole name or last `.` segment) is redacted. + */ +export declare function scrubAttributes(attributes: Attributes, tokenPaths?: readonly (readonly [RegExp, string])[]): Attributes; diff --git a/dist/redact.js b/dist/redact.js new file mode 100644 index 0000000..e22efe5 --- /dev/null +++ b/dist/redact.js @@ -0,0 +1,129 @@ +import { isAppAttributeKey } from "./app-keys.js"; +import { scrubMessage } from "./log-scrub.js"; +import { describeKeySets, describePatterns } from "./scrub-descriptor.js"; +import { stripUserinfo, URL_AUTHORITY } from "./url-credentials.js"; +import { routeTemplateOf, templateUrlPath, TOKEN_PATH_TEMPLATES } from "./url-path.js"; +const URL_KEYS = new Set(["url.full", "url.original", "http.url", "http.target", "url.path"]); +const DROPPED_KEYS = new Set([ + "url.query", + "http.request.body", + "http.response.body", + "client.address", + "network.peer.address", + "bullmq.job.result", + "bullmq.job.progress", +]); +/** Kept so a failure is visible, but its content is the thrown message. */ +const REDACTED_KEYS = new Set(["exception.message", "bullmq.job.failed.reason"]); +const DROPPED_PREFIXES = [ + "http.request.header.", + "http.response.header.", + "messaging.message.body", + "db.query.parameter.", + "db.operation.parameter.", + "enduser.", + "user.", +]; +const STATEMENT_KEYS = ["db.statement", "db.query.text"]; +// A span with either key set to this is a Redis call, whose statement quotes the values it sent. +const REDIS_SYSTEM_KEYS = ["db.system.name", "db.system"]; +const REDIS_SYSTEM = "redis"; +const URL_QUERY_START = /[?#;]/; +const FRAME_LINE = /^\s+at /; +/** `schemeless` reads a value with no scheme and no leading `/` as starting at its authority. */ +export function stripQuery(url, schemeless = false) { + // Userinfo first: a password can hold a `?` or `#`. + const withoutUserinfo = stripUserinfo(url, schemeless); + const cut = withoutUserinfo.search(URL_QUERY_START); + return cut === -1 ? withoutUserinfo : withoutUserinfo.slice(0, cut); +} +// `METHOD /path`, `METHOD scheme://host/path` or `METHOD user@host/path`. +const NAMED_REQUEST = /^([A-Z]+) ((?:\/|[a-z][a-z0-9+.-]*:\/\/|[^\s/?#@]*@)\S*)$/i; +const NAME_KEYS = new Set(["next.span_name"]); +const WORD = /\S+/g; +/** Pinned by scrub-contract.test.ts: the agent's layer-2 span scrub repeats these. */ +export const REDACT_PATTERNS = describePatterns({ + URL_AUTHORITY, + URL_QUERY_START, + NAMED_REQUEST, + FRAME_LINE, +}); +export const REDACT_KEY_SETS = describeKeySets({ + URL_KEYS, + DROPPED_KEYS, + REDACTED_KEYS, + DROPPED_PREFIXES, + STATEMENT_KEYS, + NAME_KEYS, + REDIS_SYSTEM_KEYS, +}); +export const REDACT_LITERALS = Object.freeze({ REDIS_SYSTEM }); +/** + * A span name of the form `METHOD /path`, `METHOD scheme://host/path` or `METHOD user@host/path` + * gets the same treatment as a URL attribute: no query, fragment or userinfo, and the path + * templated. Next.js names an unrouted request's span after its raw URL. Any other name gets the + * message scrub, and each of its words its token paths templated. + */ +export function scrubSpanName(name, route, tokenPaths = TOKEN_PATH_TEMPLATES) { + const match = NAMED_REQUEST.exec(name); + if (!match) { + return scrubMessage(name).replace(WORD, (word) => templateUrlPath(word, undefined, tokenPaths)); + } + return `${match[1]} ${templateUrlPath(stripQuery(match[2], true), route, tokenPaths)}`; +} +function isRedis(attributes) { + return REDIS_SYSTEM_KEYS.some((key) => attributes[key] === REDIS_SYSTEM); +} +function scrubExceptionStacktrace(stacktrace, exceptionType) { + const lines = stacktrace.split("\n"); + const type = exceptionType || "Error"; + let frameStartIndex = lines.findIndex((line) => FRAME_LINE.test(line)); + if (frameStartIndex === -1) { + return `${type}: [redacted]`; + } + const result = [`${type}: [redacted]`, ...lines.slice(frameStartIndex)]; + return result.join("\n"); +} +/** + * Scrubs one attribute set. Every URL attribute loses its query, fragment and userinfo, and its + * path becomes the span's `http.route` template when it has one, so a path parameter never + * leaves. Without a route (client spans, unmatched requests) `url.path` is dropped and known + * token-bearing paths are templated in the remaining URL attributes. An attribute named by the + * app's `redactKeys` (its whole name or last `.` segment) is redacted. + */ +export function scrubAttributes(attributes, tokenPaths = TOKEN_PATH_TEMPLATES) { + const redis = isRedis(attributes); + const route = routeTemplateOf(attributes["http.route"]); + const scrubbed = {}; + for (const [key, value] of Object.entries(attributes)) { + if (DROPPED_KEYS.has(key) || DROPPED_PREFIXES.some((prefix) => key.startsWith(prefix))) + continue; + if (redis && STATEMENT_KEYS.includes(key)) + continue; + if (key === "url.path" && !route) + continue; + if (REDACTED_KEYS.has(key) || isAppAttributeKey(key)) { + scrubbed[key] = "[redacted]"; + } + else if (key === "exception.stacktrace") { + if (typeof value === "string") { + const exceptionType = attributes["exception.type"]; + scrubbed[key] = scrubExceptionStacktrace(value, exceptionType); + } + else { + scrubbed[key] = "[redacted]"; + } + } + else { + scrubbed[key] = + typeof value !== "string" + ? value + : URL_KEYS.has(key) + ? templateUrlPath(stripQuery(value, true), route, tokenPaths) + : NAME_KEYS.has(key) + ? scrubSpanName(value, route, tokenPaths) + : value; + } + } + return scrubbed; +} diff --git a/dist/redacting-exporter.d.ts b/dist/redacting-exporter.d.ts new file mode 100644 index 0000000..5743d2c --- /dev/null +++ b/dist/redacting-exporter.d.ts @@ -0,0 +1,17 @@ +import type { ExportResult } from "@opentelemetry/core"; +import type { ReadableSpan, SpanExporter } from "@opentelemetry/sdk-trace-base"; +type TokenPaths = readonly (readonly [RegExp, string])[]; +/** Pinned by scrub-contract.test.ts: the agent's layer-2 span scrub repeats it. */ +export declare const EXPORTER_LITERALS: Readonly<{ + REDACTED_STATUS_KEY: "message"; +}>; +export declare class RedactingSpanExporter implements SpanExporter { + private readonly inner; + private readonly tokenPaths; + /** `tokenPaths` are the path patterns templated on spans without a route template. */ + constructor(inner: SpanExporter, tokenPaths?: TokenPaths); + export(spans: ReadableSpan[], done: (result: ExportResult) => void): void; + shutdown(): Promise; + forceFlush(): Promise; +} +export {}; diff --git a/dist/redacting-exporter.js b/dist/redacting-exporter.js new file mode 100644 index 0000000..32a6b99 --- /dev/null +++ b/dist/redacting-exporter.js @@ -0,0 +1,51 @@ +import { scrubAttributes, scrubSpanName } from "./redact.js"; +import { routeTemplateOf, TOKEN_PATH_TEMPLATES } from "./url-path.js"; +// The span status field that carries the thrown message; it is kept, redacted, so a failure shows. +const REDACTED_STATUS_KEY = "message"; +/** Pinned by scrub-contract.test.ts: the agent's layer-2 span scrub repeats it. */ +export const EXPORTER_LITERALS = Object.freeze({ REDACTED_STATUS_KEY }); +function scrubSpan(span, tokenPaths) { + const attributes = scrubAttributes(span.attributes, tokenPaths); + const name = scrubSpanName(span.name, routeTemplateOf(span.attributes["http.route"]), tokenPaths); + const events = span.events.map((event) => event.attributes + ? { ...event, attributes: scrubAttributes(event.attributes, tokenPaths) } + : event); + const links = span.links.map((link) => link.attributes ? { ...link, attributes: scrubAttributes(link.attributes, tokenPaths) } : link); + const status = span.status && span.status[REDACTED_STATUS_KEY] + ? { ...span.status, [REDACTED_STATUS_KEY]: "[redacted]" } + : span.status; + return new Proxy(span, { + get(target, property) { + if (property === "name") + return name; + if (property === "attributes") + return attributes; + if (property === "events") + return events; + if (property === "links") + return links; + if (property === "status") + return status; + const value = Reflect.get(target, property, target); + return typeof value === "function" ? value.bind(target) : value; + }, + }); +} +export class RedactingSpanExporter { + inner; + tokenPaths; + /** `tokenPaths` are the path patterns templated on spans without a route template. */ + constructor(inner, tokenPaths = TOKEN_PATH_TEMPLATES) { + this.inner = inner; + this.tokenPaths = tokenPaths; + } + export(spans, done) { + this.inner.export(spans.map((span) => scrubSpan(span, this.tokenPaths)), done); + } + shutdown() { + return this.inner.shutdown(); + } + forceFlush() { + return this.inner.forceFlush?.() ?? Promise.resolve(); + } +} diff --git a/dist/register.d.ts b/dist/register.d.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/dist/register.d.ts @@ -0,0 +1 @@ +export {}; diff --git a/dist/register.js b/dist/register.js new file mode 100644 index 0000000..8275750 --- /dev/null +++ b/dist/register.js @@ -0,0 +1,5 @@ +// `node --import @codelittinc/otel-node/register`: starts telemetry before the app loads, for an +// app with no options to pass. `OTEL_SERVICE_NAME` names the service and is its log scope. +import { startTelemetry } from "./sdk.js"; +const serviceName = process.env.OTEL_SERVICE_NAME?.trim() || "unknown"; +await startTelemetry({ serviceName, logScope: serviceName }); diff --git a/dist/scrub-descriptor.d.ts b/dist/scrub-descriptor.d.ts new file mode 100644 index 0000000..406393c --- /dev/null +++ b/dist/scrub-descriptor.d.ts @@ -0,0 +1,7 @@ +/** A regex's source and flags, for the scrub contract test; never the stateful RegExp itself. */ +export interface PatternDescriptor { + readonly source: string; + readonly flags: string; +} +export declare function describePatterns(patterns: Record): Readonly>; +export declare function describeKeySets(sets: Record>): Readonly>; diff --git a/dist/scrub-descriptor.js b/dist/scrub-descriptor.js new file mode 100644 index 0000000..38c8850 --- /dev/null +++ b/dist/scrub-descriptor.js @@ -0,0 +1,14 @@ +export function describePatterns(patterns) { + const out = {}; + for (const name of Object.keys(patterns)) { + const { source, flags } = patterns[name]; + out[name] = Object.freeze({ source, flags }); + } + return Object.freeze(out); +} +export function describeKeySets(sets) { + const out = {}; + for (const name of Object.keys(sets)) + out[name] = Object.freeze([...sets[name]].sort()); + return Object.freeze(out); +} diff --git a/dist/sdk-boot.d.ts b/dist/sdk-boot.d.ts new file mode 100644 index 0000000..b0ed72d --- /dev/null +++ b/dist/sdk-boot.d.ts @@ -0,0 +1,23 @@ +import type { Telemetry } from "bullmq"; +import type { Instrumentation } from "@opentelemetry/instrumentation"; +import type { TelemetryConfig } from "./config.js"; +import type { MetricViewOptions } from "./options.js"; +export interface BootedSdk { + shutdown(): Promise; + bullmq: Telemetry; +} +export interface BootOptions { + logScope: string; + ignoredPaths?: readonly string[]; + esmLoaderHook?: boolean; + exportLogs?: boolean; + metricViews?: MetricViewOptions; + instrumentations?: readonly Instrumentation[]; +} +/** + * Registers the ESM loader hook (unless `esmLoaderHook` is false) and starts the Node SDK. Loaded + * only once telemetry is enabled, so a process without an endpoint never pays for the SDK and its + * instrumentations. With `exportLogs`, every line the shared logger writes is also sent over OTLP + * under `logScope`. + */ +export declare function bootSdk(config: TelemetryConfig, options: BootOptions): Promise; diff --git a/dist/sdk-boot.js b/dist/sdk-boot.js new file mode 100644 index 0000000..b522e03 --- /dev/null +++ b/dist/sdk-boot.js @@ -0,0 +1,123 @@ +import { register } from "node:module"; +import { createAddHookMessageChannel } from "import-in-the-middle"; +import { NodeSDK } from "@opentelemetry/sdk-node"; +import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto"; +import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-proto"; +import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics"; +import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base"; +import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-proto"; +import { BatchLogRecordProcessor } from "@opentelemetry/sdk-logs"; +import { logs } from "@opentelemetry/api-logs"; +import { resourceFromAttributes } from "@opentelemetry/resources"; +import { ATTR_SERVICE_INSTANCE_ID, ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION, } from "@opentelemetry/semantic-conventions"; +import { BullMQOtel } from "bullmq-otel"; +import { buildInstrumentations } from "./instrumentations.js"; +import { metricViews } from "./metric-views.js"; +import { RedactingSpanExporter } from "./redacting-exporter.js"; +import { logSafely } from "./log-safely.js"; +import { setLogLineSink } from "./log-sink.js"; +import { logLineSink, ScopedLogRecordProcessor } from "./log-otlp.js"; +/** Longest boot waits for the loader thread to acknowledge the hook registration. */ +const HOOK_ACK_TIMEOUT_MS = 5_000; +/** + * Log records per OTLP request. Alloy refuses a whole request over 32 MiB + * (`agent/config/base.alloy` in codelittinc/carbon-observability), and the logger does not cap a + * line, so a batch is bounded by count: 64 records stay under the limit unless they average over + * 512 KiB each. The SDK default of 512 would fail at 64 KiB each. + */ +const LOG_EXPORT_BATCH_SIZE = 64; +/** + * Registers the ESM loader hook (unless `esmLoaderHook` is false) and starts the Node SDK. Loaded + * only once telemetry is enabled, so a process without an endpoint never pays for the SDK and its + * instrumentations. With `exportLogs`, every line the shared logger writes is also sent over OTLP + * under `logScope`. + */ +export async function bootSdk(config, options) { + const { logScope, esmLoaderHook = true, exportLogs = true } = options; + // A message channel limits import-in-the-middle to modules the instrumentations register, + // instead of wrapping every ESM module the app loads. + const hook = esmLoaderHook ? createAddHookMessageChannel() : undefined; + if (hook) + register("import-in-the-middle/hook.mjs", import.meta.url, hook.registerOptions); + const sdk = new NodeSDK({ + resource: resourceFromAttributes({ + [ATTR_SERVICE_NAME]: config.serviceName, + [ATTR_SERVICE_VERSION]: config.serviceVersion, + [ATTR_SERVICE_INSTANCE_ID]: config.serviceInstanceId, + "deployment.environment.name": config.environment, + }), + spanProcessors: [new BatchSpanProcessor(new RedactingSpanExporter(new OTLPTraceExporter()))], + metricReaders: [ + new PeriodicExportingMetricReader({ + exporter: new OTLPMetricExporter(), + exportIntervalMillis: 60_000, + }), + ], + views: metricViews(options.metricViews), + // Never omitted: an omitted list makes NodeSDK build its own log exporter from env. Records + // come only from the logger's scrubbed output (see log-otlp.ts), never from an + // instrumentation that sees a log call before it is scrubbed. + logRecordProcessors: exportLogs + ? [ + new ScopedLogRecordProcessor(new BatchLogRecordProcessor({ + exporter: new OTLPLogExporter(), + maxExportBatchSize: LOG_EXPORT_BATCH_SIZE, + }), logScope), + ] + : [], + instrumentations: buildInstrumentations(options.ignoredPaths ?? [], options.instrumentations), + }); + sdk.start(); + const booted = { + shutdown: () => { + setLogLineSink(undefined); + // After the records the sink has already queued for emission. + return Promise.resolve().then(() => sdk.shutdown()); + }, + bullmq: new BullMQOtel({ tracerName: logScope }), + }; + if (exportLogs) + setLogLineSink(logLineSink(logs.getLogger(logScope))); + if (hook) + await acknowledgedWithin(hook.waitForAllMessagesAcknowledged, HOOK_ACK_TIMEOUT_MS); + return booted; +} +/** + * Abandons a loader acknowledgement that never arrives, so boot cannot hang. The wait keeps a + * referenced interval alive until the ack lands; on timeout it is unreferenced, so an abandoned + * ack never holds the process open. + */ +async function acknowledgedWithin(wait, timeoutMs) { + const { result: ack, intervals } = captureIntervals(wait); + let timer; + const timeout = new Promise((resolve) => { + timer = setTimeout(() => resolve("timeout"), timeoutMs); + timer.unref(); + }); + try { + if ((await Promise.race([ack, timeout])) === "timeout") { + for (const interval of intervals) + interval.unref(); + logSafely("warn", { event: "telemetry_hook_ack_timeout" }, "telemetry_hook_ack_timeout"); + } + } + finally { + clearTimeout(timer); + } +} +/** Runs `fn` and returns the intervals it created synchronously. */ +function captureIntervals(fn) { + const intervals = []; + const original = globalThis.setInterval; + globalThis.setInterval = ((...args) => { + const interval = original(...args); + intervals.push(interval); + return interval; + }); + try { + return { result: fn(), intervals }; + } + finally { + globalThis.setInterval = original; + } +} diff --git a/dist/sdk.d.ts b/dist/sdk.d.ts new file mode 100644 index 0000000..f7434a9 --- /dev/null +++ b/dist/sdk.d.ts @@ -0,0 +1,26 @@ +import { type TelemetryConfig } from "./config.js"; +import { type TelemetryOptions } from "./options.js"; +/** + * Boots the OpenTelemetry Node SDK. Never rejects: a failed start is logged and the config + * is returned. Telemetry starts once per process; a restart after `shutdownTelemetry` is not + * supported (shutdown resets state only so tests can run in sequence). The ESM loader hook is + * registered here, so this module must run under `node --import` before the app loads `http`. + * The SDK itself is imported only once telemetry is enabled. A host that runs bundled CommonJS + * (Next.js) passes `esmLoaderHook: false`. The log scope, `redactKeys` and `onError` apply even + * when export is off, since they shape stdout and error reporting too. The first call's options + * win whether or not it exported; a later call may only turn export on through its `env`, and + * warns `telemetry_restart_ignored` when its options differ. + */ +export declare function startTelemetry(options: TelemetryOptions): Promise; +/** True once `startTelemetry` has booted the SDK in this process. */ +export declare function isTelemetryStarted(): boolean; +/** + * The BullMQ adapter built by `startTelemetry`; undefined until telemetry has started. Typed + * `unknown` so this module's types never name bullmq; `./bullmq` gives it its type. + */ +export declare function startedBullmqTelemetry(): unknown; +/** + * Flushes pending spans, metrics, log records and profiles, bounded so a dead collector cannot + * stall a shutdown. + */ +export declare function shutdownTelemetry(timeoutMs?: number): Promise; diff --git a/dist/sdk.js b/dist/sdk.js new file mode 100644 index 0000000..b9fe33f --- /dev/null +++ b/dist/sdk.js @@ -0,0 +1,133 @@ +import { diag, DiagLogLevel } from "@opentelemetry/api"; +import { resolveTelemetryConfig } from "./config.js"; +import { diagLogger, logSafely } from "./log-safely.js"; +import { applyOptions, checkOptions, logScope, sameOptions, } from "./options.js"; +import { startProfiling, stopProfiling } from "./profiling.js"; +let sdk; +let started; +let applied; +/** + * Boots the OpenTelemetry Node SDK. Never rejects: a failed start is logged and the config + * is returned. Telemetry starts once per process; a restart after `shutdownTelemetry` is not + * supported (shutdown resets state only so tests can run in sequence). The ESM loader hook is + * registered here, so this module must run under `node --import` before the app loads `http`. + * The SDK itself is imported only once telemetry is enabled. A host that runs bundled CommonJS + * (Next.js) passes `esmLoaderHook: false`. The log scope, `redactKeys` and `onError` apply even + * when export is off, since they shape stdout and error reporting too. The first call's options + * win whether or not it exported; a later call may only turn export on through its `env`, and + * warns `telemetry_restart_ignored` when its options differ. + */ +export async function startTelemetry(options) { + let effective; + let env; + let config; + try { + effective = resolveOptions(options); + if (started) + return started; + env = effective.env ?? process.env; + config = resolveTelemetryConfig(effective.serviceName, env); + } + catch (error) { + logSafely("warn", { err: error, event: "telemetry_start_failed" }, "telemetry_start_failed"); + if (started) + return started; + const name = typeof options?.serviceName === "string" ? options.serviceName : "unknown"; + return { ...resolveTelemetryConfig(name, {}), enabled: false }; + } + if (!config.enabled) { + await startProfiling(config, env); + return config; + } + started = config; + installDiagLogger(env.OTEL_LOG_LEVEL); + try { + const { bootSdk } = await import("./sdk-boot.js"); + sdk = await bootSdk(config, { + logScope: logScope(), + ignoredPaths: effective.ignoredPaths ?? [], + esmLoaderHook: effective.esmLoaderHook !== false, + exportLogs: effective.logs !== false, + metricViews: effective.metricViews, + instrumentations: effective.instrumentations, + }); + logSafely("info", { + event: "telemetry_started", + serviceName: config.serviceName, + endpointHost: hostOf(env.OTEL_EXPORTER_OTLP_ENDPOINT), + }, "telemetry_started"); + } + catch (error) { + sdk = undefined; + logSafely("warn", { err: error, event: "telemetry_start_failed" }, "telemetry_start_failed"); + } + await startProfiling(config, env); + return config; +} +/** The first call's options, applied once; a later call keeps them and only brings its `env`. */ +function resolveOptions(input) { + const { options, invalidKeyTypes } = checkOptions(input); + if (applied) { + if (!sameOptions(applied, options)) { + logSafely("warn", { event: "telemetry_restart_ignored" }, "telemetry_restart_ignored"); + } + return { ...applied, env: options.env }; + } + if (invalidKeyTypes.length > 0) { + logSafely("warn", { event: "invalid_redact_key", types: invalidKeyTypes }, "invalid_redact_key"); + } + applyOptions(options); + applied = options; + return options; +} +/** True once `startTelemetry` has booted the SDK in this process. */ +export function isTelemetryStarted() { + return sdk !== undefined; +} +/** + * The BullMQ adapter built by `startTelemetry`; undefined until telemetry has started. Typed + * `unknown` so this module's types never name bullmq; `./bullmq` gives it its type. + */ +export function startedBullmqTelemetry() { + return sdk?.bullmq; +} +/** + * Flushes pending spans, metrics, log records and profiles, bounded so a dead collector cannot + * stall a shutdown. + */ +export async function shutdownTelemetry(timeoutMs = 5_000) { + started = undefined; + applied = undefined; + const running = sdk; + sdk = undefined; + const timeout = new Promise((resolve) => setTimeout(resolve, timeoutMs).unref()); + await Promise.race([ + Promise.all([ + stopProfiling(), + Promise.resolve() + .then(() => running?.shutdown()) + .catch(() => undefined), + ]).then(() => undefined), + timeout, + ]); +} +/** SDK warnings (a rejected export, a bad option) go through the redacting logger. When + * `OTEL_LOG_LEVEL` is set, NodeSDK installs its own console logger at that level for debugging, + * so this default stays out of its way. */ +function installDiagLogger(level) { + if (level?.trim()) + return; + diag.setLogger(diagLogger, { + logLevel: DiagLogLevel.WARN, + suppressOverrideMessage: true, + }); +} +/** The endpoint's host only: a URL can carry credentials in its userinfo or query. */ +function hostOf(endpoint) { + try { + return new URL(endpoint?.trim() ?? "").host; + } + catch { + return "invalid"; + } +} diff --git a/dist/shared-log.d.ts b/dist/shared-log.d.ts new file mode 100644 index 0000000..01e59f3 --- /dev/null +++ b/dist/shared-log.d.ts @@ -0,0 +1,19 @@ +import type { Logger } from "pino"; +/** The configured service name, else `OTEL_SERVICE_NAME`, else "unknown". */ +export declare function currentService(): string; +/** + * The one logger every process shares. Apps import it directly; modules and packages log through a + * child that binds `component` (`log.child({ component: "db" })`), so their lines carry the + * process's name in `app`. + */ +export declare const log: Logger; +/** + * Names the process on every log line (the `app` field), including lines from loggers created + * before this call. Each entry point calls it once. The first name wins: a later call with a + * different name is ignored and warns, so no imported module can rename the process. + */ +export declare function configureLogger({ service }: { + service: string; +}): void; +/** Resolves once buffered log lines are written, or after `timeoutMs`; never rejects. */ +export declare function flushLog(timeoutMs?: number): Promise; diff --git a/dist/shared-log.js b/dist/shared-log.js new file mode 100644 index 0000000..12aee07 --- /dev/null +++ b/dist/shared-log.js @@ -0,0 +1,43 @@ +import { announceLogLevel, createLogger } from "./logger.js"; +let configuredService; +/** The configured service name, else `OTEL_SERVICE_NAME`, else "unknown". */ +export function currentService() { + return configuredService ?? (process.env.OTEL_SERVICE_NAME?.trim() || "unknown"); +} +/** + * The one logger every process shares. Apps import it directly; modules and packages log through a + * child that binds `component` (`log.child({ component: "db" })`), so their lines carry the + * process's name in `app`. + */ +export const log = createLogger(currentService); +/** + * Names the process on every log line (the `app` field), including lines from loggers created + * before this call. Each entry point calls it once. The first name wins: a later call with a + * different name is ignored and warns, so no imported module can rename the process. + */ +export function configureLogger({ service }) { + if (configuredService === undefined) { + configuredService = service; + announceLogLevel(log); + return; + } + if (service !== configuredService) { + log.warn({ event: "logger_reconfigure_ignored", requested: service }, "logger already configured, keeping the first service name"); + } +} +/** Resolves once buffered log lines are written, or after `timeoutMs`; never rejects. */ +export function flushLog(timeoutMs = 1_000) { + return new Promise((resolve) => { + const timer = setTimeout(resolve, timeoutMs); + try { + log.flush(() => { + clearTimeout(timer); + resolve(); + }); + } + catch { + clearTimeout(timer); + resolve(); + } + }); +} diff --git a/dist/testing.d.ts b/dist/testing.d.ts new file mode 100644 index 0000000..18dbbe1 --- /dev/null +++ b/dist/testing.d.ts @@ -0,0 +1,6 @@ +import type { Logger } from "pino"; +/** A logger that captures parsed records in memory, for tests. */ +export declare function createTestLogger(): { + logger: Logger; + records: Record[]; +}; diff --git a/dist/testing.js b/dist/testing.js new file mode 100644 index 0000000..95d7837 --- /dev/null +++ b/dist/testing.js @@ -0,0 +1,12 @@ +import { createLogger } from "./logger.js"; +import { currentService } from "./shared-log.js"; +/** A logger that captures parsed records in memory, for tests. */ +export function createTestLogger() { + const records = []; + const logger = createLogger(currentService, { + write(line) { + records.push(JSON.parse(line)); + }, + }); + return { logger, records }; +} diff --git a/dist/trace-context.d.ts b/dist/trace-context.d.ts new file mode 100644 index 0000000..db26ac6 --- /dev/null +++ b/dist/trace-context.d.ts @@ -0,0 +1,16 @@ +import { type Link } from "@opentelemetry/api"; +/** The W3C `traceparent` of the active span, or undefined when no span is active. */ +export declare function currentTraceparent(): string | undefined; +/** A span link to a stored `traceparent`. A missing or malformed value gives no link. */ +export declare function linkFromTraceparent(value: string | null | undefined): Link | undefined; +/** + * Run `fn` inside a new active span that links to the trace in `traceparent`. A link, not a + * parent: the work runs later and apart from the request that stored the value. + */ +export declare function withLinkedSpan(name: string, traceparent: string | null | undefined, fn: () => Promise): Promise; +/** + * Run `fn` inside a new active root span, recorded even where the HTTP instrumentation suppressed + * tracing for an ignored path. The span ends with ERROR when `fn` throws or `failed` holds for its + * result, else OK. + */ +export declare function withRootSpan(name: string, fn: () => Promise, failed?: (result: T) => boolean): Promise; diff --git a/dist/trace-context.js b/dist/trace-context.js new file mode 100644 index 0000000..63e47fa --- /dev/null +++ b/dist/trace-context.js @@ -0,0 +1,71 @@ +import { context, defaultTextMapGetter, defaultTextMapSetter, isSpanContextValid, ROOT_CONTEXT, SpanStatusCode, trace, } from "@opentelemetry/api"; +import { unsuppressTracing, W3CTraceContextPropagator } from "@opentelemetry/core"; +// The W3C propagator is used directly, not the global one, so a stored value is always a +// `traceparent` header whatever OTEL_PROPAGATORS says. +const w3c = new W3CTraceContextPropagator(); +const TRACER_NAME = "@codelittinc/otel-node"; +/** The W3C `traceparent` of the active span, or undefined when no span is active. */ +export function currentTraceparent() { + const carrier = {}; + w3c.inject(context.active(), carrier, defaultTextMapSetter); + return carrier.traceparent; +} +/** A span link to a stored `traceparent`. A missing or malformed value gives no link. */ +export function linkFromTraceparent(value) { + if (!value) + return undefined; + try { + const extracted = w3c.extract(ROOT_CONTEXT, { traceparent: value }, defaultTextMapGetter); + const spanContext = trace.getSpanContext(extracted); + return spanContext && isSpanContextValid(spanContext) ? { context: spanContext } : undefined; + } + catch { + return undefined; + } +} +/** + * Run `fn` inside a new active span that links to the trace in `traceparent`. A link, not a + * parent: the work runs later and apart from the request that stored the value. + */ +export function withLinkedSpan(name, traceparent, fn) { + const link = linkFromTraceparent(traceparent); + return trace + .getTracer(TRACER_NAME) + .startActiveSpan(name, { links: link ? [link] : [] }, async (span) => { + try { + return await fn(); + } + catch (err) { + span.recordException(err instanceof Error ? err : String(err)); + span.setStatus({ code: SpanStatusCode.ERROR }); + throw err; + } + finally { + span.end(); + } + }); +} +/** + * Run `fn` inside a new active root span, recorded even where the HTTP instrumentation suppressed + * tracing for an ignored path. The span ends with ERROR when `fn` throws or `failed` holds for its + * result, else OK. + */ +export function withRootSpan(name, fn, failed = () => false) { + return trace + .getTracer(TRACER_NAME) + .startActiveSpan(name, { root: true }, unsuppressTracing(context.active()), async (span) => { + try { + const result = await fn(); + span.setStatus({ code: failed(result) ? SpanStatusCode.ERROR : SpanStatusCode.OK }); + return result; + } + catch (err) { + span.recordException(err instanceof Error ? err : String(err)); + span.setStatus({ code: SpanStatusCode.ERROR }); + throw err; + } + finally { + span.end(); + } + }); +} diff --git a/dist/url-credentials.d.ts b/dist/url-credentials.d.ts new file mode 100644 index 0000000..891da91 --- /dev/null +++ b/dist/url-credentials.d.ts @@ -0,0 +1,23 @@ +export declare const URL_TOKEN: RegExp; +export declare const URL_START: RegExp; +export declare const USERINFO_END: RegExp; +export declare const URL_END: RegExp; +export declare const QUOTE_END: RegExp; +export declare const USERINFO_QUOTE: RegExp; +export declare const HOST_PORT: RegExp; +export declare const URL_AUTHORITY: RegExp; +export declare const QUERY_START: RegExp; +/** + * In a URL token, each URL loses its userinfo, and a URL with a `?`, `#` or `;` after its + * authority loses everything from there to the next whitespace. A host and path run to URL_END, + * QUOTE_END or the next URL's start, whichever comes first. + */ +export declare function scrubUrlToken(token: string): string; +/** Every URL in free text, scrubbed as `scrubUrlToken` does. */ +export declare function scrubUrls(text: string): string; +/** + * A URL value without its userinfo. The authority starts after URL_AUTHORITY. With `schemeless`, + * a value without one that is not a path starts its authority at its first non-space character. + * The value is one URL, so its userinfo may run to the value's end. + */ +export declare function stripUserinfo(value: string, schemeless?: boolean): string; diff --git a/dist/url-credentials.js b/dist/url-credentials.js new file mode 100644 index 0000000..d7b0f9c --- /dev/null +++ b/dist/url-credentials.js @@ -0,0 +1,147 @@ +// URL userinfo and query removal, shared by the message scrub and the URL value scrub. It imports +// nothing, so a browser bundle can load it from the `./url-credentials` entry. +// +// Every scan moves forward through the text once, so a scrub is linear in the input length. +// Free text splits into URL tokens at a space or a line break only, so a tab or another space +// inside a password does not end its URL. +export const URL_TOKEN = /[^ \n\r]+/g; +// Where a URL's authority starts: after a scheme's `://` or JSON-escaped `:\/\/` (matched from +// the start of its run of scheme characters); after a scheme's `:/`, or a `//`, at the start of a +// token or after a delimiter. +export const URL_START = /(?]/; +// A host and path also end at any whitespace. +export const URL_END = /[\s`<>]/; +// A quote ends a URL only at the token's end or before whitespace or a JSON or list delimiter. +export const QUOTE_END = /["'](?=$|[\s,;:}\])>"'&])/y; +// A `'` before `)` or `'`, which an encoded password keeps raw, does not end userinfo. +export const USERINFO_QUOTE = /'[)']/y; +// Text before an authority's first `/`, `?`, `#` or `\` that reads as a host and an optional port +// (a bracketed IPv6 host, or a non-empty host with no `:`, then `:` and digits or nothing). +export const HOST_PORT = /^(?:\[[^\]]*\]|[^:[\]]+)(?::\d*)?$/; +// The leading `scheme:` chain and one or two `/` (or `\/\/`), or a bare `//`, of a URL value. +export const URL_AUTHORITY = /^\s*(?:(?:[0-9+.-]*[a-z][a-z0-9+.-]*:)+(?:\/\/?|\\\/\\\/)|\/\/)/i; +export const QUERY_START = /[?#;]/; +/** The first index at or after `from` whose character passes `test`, or the text's length. */ +function finder(text, test) { + let lastFrom = -1; + let found = -1; + return (from) => { + // Calls whose `from` never decreases reuse the last result, which keeps a scan linear. + if (from < lastFrom || found < from) { + found = from; + while (found < text.length && !test(text[found], found)) + found++; + } + lastFrom = from; + return found; + }; +} +const isDelimiter = (char) => char === "/" || char === "?" || char === "#" || char === "\\"; +function scanner(text) { + return { + at: finder(text, (char) => char === "@"), + delimiter: finder(text, isDelimiter), + slash: finder(text, (char) => char === "/"), + queryValue: finder(text, (char) => char === "=" || char === "/"), + delimiterAfterAt: finder(text, isDelimiter), + }; +} +/** + * Where the authority at `from` continues past its userinfo, which runs to the last `@` before + * the first `/`, `?`, `#` or `\` after its first `@`, within `end`. Text before a delimiter that + * comes ahead of the first `@` is a host, not userinfo: + * - with a `:` or a leading `[`, when HOST_PORT matches and the head is bracketed, the delimiter + * is `/` or an escaped `\/`, or a `?` or `#` delimiter has a `=` or `/` after it before the `@`; + * - with neither, when a `/` comes before the `@`. + * A `//` or `://` inside userinfo starts no URL. Returns `from` when there is no userinfo. + */ +function afterUserinfo(text, from, end, scan) { + const at = scan.at(from); + if (at >= end) + return from; + const delimiter = scan.delimiter(from); + if (delimiter < at) { + const head = text.slice(from, delimiter); + if (head.includes(":") || head.startsWith("[")) { + const sign = text[delimiter]; + const hostEvidence = head.startsWith("[") || + sign === "/" || + (sign === "\\" ? text[delimiter + 1] === "/" : scan.queryValue(delimiter) < at); + if (hostEvidence && HOST_PORT.test(head)) + return from; + } + else if (scan.slash(delimiter) < at) { + return from; + } + } + const close = Math.min(scan.delimiterAfterAt(at + 1), end); + return text.lastIndexOf("@", close - 1) + 1; +} +/** + * In a URL token, each URL loses its userinfo, and a URL with a `?`, `#` or `;` after its + * authority loses everything from there to the next whitespace. A host and path run to URL_END, + * QUOTE_END or the next URL's start, whichever comes first. + */ +export function scrubUrlToken(token) { + URL_START.lastIndex = 0; + let start = URL_START.exec(token); + if (!start) + return token; + const scan = scanner(token); + const quoteEnds = (char, index) => { + if (char !== '"' && char !== "'") + return false; + QUOTE_END.lastIndex = index; + return QUOTE_END.test(token); + }; + const keptQuote = (index) => { + USERINFO_QUOTE.lastIndex = index; + return USERINFO_QUOTE.test(token); + }; + const userinfoEnd = finder(token, (char, i) => USERINFO_END.test(char) || (quoteEnds(char, i) && !keptQuote(i))); + const urlEnd = finder(token, (char, i) => URL_END.test(char) || quoteEnds(char, i)); + const query = finder(token, (char) => QUERY_START.test(char)); + const space = finder(token, (char) => /\s/.test(char)); + let out = token.slice(0, start.index); + while (start) { + const authority = start.index + start[0].length; + const host = afterUserinfo(token, authority, userinfoEnd(authority), scan); + const end = urlEnd(host); + URL_START.lastIndex = host; + let next = URL_START.exec(token); + const cut = query(host); + out += token.slice(start.index, authority); + let resume = host; + if (cut < (next ? Math.min(next.index, end) : end)) { + out += token.slice(host, cut); + resume = space(cut); + if (next && next.index < resume) { + URL_START.lastIndex = resume; + next = URL_START.exec(token); + } + } + out += token.slice(resume, next ? next.index : token.length); + start = next; + } + return out; +} +/** Every URL in free text, scrubbed as `scrubUrlToken` does. */ +export function scrubUrls(text) { + return text.replace(URL_TOKEN, scrubUrlToken); +} +/** + * A URL value without its userinfo. The authority starts after URL_AUTHORITY. With `schemeless`, + * a value without one that is not a path starts its authority at its first non-space character. + * The value is one URL, so its userinfo may run to the value's end. + */ +export function stripUserinfo(value, schemeless = false) { + const lead = URL_AUTHORITY.exec(value); + const authority = lead ? lead[0].length : value.length - value.trimStart().length; + if (!lead && (!schemeless || value.startsWith("/", authority))) + return value; + const host = afterUserinfo(value, authority, value.length, scanner(value)); + return value.slice(0, authority) + value.slice(host); +} diff --git a/dist/url-path.d.ts b/dist/url-path.d.ts new file mode 100644 index 0000000..533e054 --- /dev/null +++ b/dist/url-path.d.ts @@ -0,0 +1,26 @@ +/** + * Paths whose segments are bearer secrets, with the template each becomes. Anchored at the start + * of the path. An applicant intake token opens and submits an application for whoever holds it: + * the web page (`/apply/[token]`) and the api routes it calls. Any letter case and any run of `/` + * match, as a server routes them alike. + */ +export declare const TOKEN_PATH_TEMPLATES: readonly (readonly [RegExp, string])[]; +/** Pinned by scrub-contract.test.ts: the agent's layer-2 span scrub repeats these. */ +export declare const URL_PATH_PATTERNS: Readonly>; +export declare const URL_PATH_LITERALS: Readonly<{ + ROUTE_TEMPLATE_PREFIX: "/"; +}>; +export declare const TOKEN_PATH_CONTRACT: readonly Readonly<{ + source: string; + flags: string; + replacement: string; +}>[]; +/** + * The URL with its path replaced by the route template when there is one, else with any token + * segment templated. The origin is kept; the query and fragment are the caller's to strip. A + * leading `//` may open a path rather than a host, so it is never kept beside a route, and a + * token path right after it is templated as a path. Leading whitespace is kept as it is. + */ +export declare function templateUrlPath(value: string, route: string | undefined, tokenPaths?: readonly (readonly [RegExp, string])[]): string; +/** A usable route template: a path, and not the shared label for unmatched requests. */ +export declare function routeTemplateOf(route: unknown): string | undefined; diff --git a/dist/url-path.js b/dist/url-path.js new file mode 100644 index 0000000..c1630ce --- /dev/null +++ b/dist/url-path.js @@ -0,0 +1,52 @@ +import { describePatterns } from "./scrub-descriptor.js"; +/** + * Paths whose segments are bearer secrets, with the template each becomes. Anchored at the start + * of the path. An applicant intake token opens and submits an application for whoever holds it: + * the web page (`/apply/[token]`) and the api routes it calls. Any letter case and any run of `/` + * match, as a server routes them alike. + */ +export const TOKEN_PATH_TEMPLATES = [ + [/^\/+v1\/+applications\/+by-token\/+[^/?#]+/i, "/v1/applications/by-token/:token"], + [/^\/+apply\/+[^/?#]+/i, "/apply/[token]"], +]; +// A `scheme:` chain and `//`, or a bare `//`, then the host. +const ORIGIN = /^((?:[a-z][a-z0-9+.-]*:)*\/\/[^/?#]*)(.*)$/is; +// An `http.route` is a template only when it is a path. +const ROUTE_TEMPLATE_PREFIX = "/"; +/** Pinned by scrub-contract.test.ts: the agent's layer-2 span scrub repeats these. */ +export const URL_PATH_PATTERNS = describePatterns({ ORIGIN }); +export const URL_PATH_LITERALS = Object.freeze({ ROUTE_TEMPLATE_PREFIX }); +export const TOKEN_PATH_CONTRACT = Object.freeze(TOKEN_PATH_TEMPLATES.map(([pattern, replacement]) => Object.freeze({ source: pattern.source, flags: pattern.flags, replacement }))); +/** Splits `scheme://host/path` or `//host/path` into its origin and the rest; a path has none. */ +function splitOrigin(value) { + const match = ORIGIN.exec(value); + return match ? [match[1], match[2]] : ["", value]; +} +function templatePath(path, tokenPaths) { + return tokenPaths.reduce((out, [pattern, template]) => out.replace(pattern, template), path); +} +/** + * The URL with its path replaced by the route template when there is one, else with any token + * segment templated. The origin is kept; the query and fragment are the caller's to strip. A + * leading `//` may open a path rather than a host, so it is never kept beside a route, and a + * token path right after it is templated as a path. Leading whitespace is kept as it is. + */ +export function templateUrlPath(value, route, tokenPaths = TOKEN_PATH_TEMPLATES) { + const url = value.trimStart(); + const lead = value.slice(0, value.length - url.length); + const [origin, rest] = splitOrigin(url); + if (origin.startsWith("/")) { + if (route) + return `${lead}${route}`; + const asPath = templatePath(url, tokenPaths); + if (asPath !== url) + return `${lead}${asPath}`; + } + if (route) + return `${lead}${origin}${route}`; + return `${lead}${origin}${templatePath(rest, tokenPaths)}`; +} +/** A usable route template: a path, and not the shared label for unmatched requests. */ +export function routeTemplateOf(route) { + return typeof route === "string" && route.startsWith(ROUTE_TEMPLATE_PREFIX) ? route : undefined; +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..ff9a427 --- /dev/null +++ b/package.json @@ -0,0 +1,86 @@ +{ + "name": "@codelittinc/otel-node", + "version": "0.1.0", + "description": "In-process OpenTelemetry for Node services: SDK boot, layer-1 PII scrub, OTLP logs.", + "license": "UNLICENSED", + "repository": "github:codelittinc/otel-node", + "type": "module", + "packageManager": "pnpm@10.33.1", + "engines": { + "node": ">=22" + }, + "files": [ + "dist" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./bullmq": { + "types": "./dist/bullmq.d.ts", + "default": "./dist/bullmq.js" + }, + "./testing": { + "types": "./dist/testing.d.ts", + "default": "./dist/testing.js" + }, + "./register": { + "types": "./dist/register.d.ts", + "default": "./dist/register.js" + }, + "./url-credentials": { + "types": "./dist/url-credentials.d.ts", + "default": "./dist/url-credentials.js" + } + }, + "scripts": { + "build": "rm -rf dist && tsc -p tsconfig.build.json", + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@opentelemetry/api": "1.9.1", + "@opentelemetry/api-logs": "0.222.0", + "@opentelemetry/core": "2.11.0", + "@opentelemetry/exporter-logs-otlp-proto": "0.222.0", + "@opentelemetry/exporter-metrics-otlp-proto": "0.222.0", + "@opentelemetry/exporter-trace-otlp-proto": "0.222.0", + "@opentelemetry/instrumentation": "0.222.0", + "@opentelemetry/instrumentation-http": "0.222.0", + "@opentelemetry/instrumentation-ioredis": "0.70.0", + "@opentelemetry/instrumentation-pg": "0.74.0", + "@opentelemetry/instrumentation-runtime-node": "0.35.0", + "@opentelemetry/instrumentation-undici": "0.32.0", + "@opentelemetry/resources": "2.11.0", + "@opentelemetry/sdk-logs": "0.222.0", + "@opentelemetry/sdk-metrics": "2.11.0", + "@opentelemetry/sdk-node": "0.222.0", + "@opentelemetry/sdk-trace-base": "2.11.0", + "@opentelemetry/semantic-conventions": "1.43.0", + "@pyroscope/nodejs": "0.6.4", + "bullmq-otel": "2.0.1", + "import-in-the-middle": "3.5.1", + "pino": "10.3.1" + }, + "devDependencies": { + "@opentelemetry/context-async-hooks": "2.11.0", + "@types/node": "22.19.17", + "bullmq": "6.3.8", + "typescript": "5.9.3", + "vitest": "3.2.7" + }, + "peerDependencies": { + "bullmq": "^6.3.8" + }, + "peerDependenciesMeta": { + "bullmq": { + "optional": true + } + }, + "pnpm": { + "onlyBuiltDependencies": [ + "esbuild" + ] + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..26d49f8 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,2264 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@opentelemetry/api': + specifier: 1.9.1 + version: 1.9.1 + '@opentelemetry/api-logs': + specifier: 0.222.0 + version: 0.222.0 + '@opentelemetry/core': + specifier: 2.11.0 + version: 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-logs-otlp-proto': + specifier: 0.222.0 + version: 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-proto': + specifier: 0.222.0 + version: 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-proto': + specifier: 0.222.0 + version: 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': + specifier: 0.222.0 + version: 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-http': + specifier: 0.222.0 + version: 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-ioredis': + specifier: 0.70.0 + version: 0.70.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-pg': + specifier: 0.74.0 + version: 0.74.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-runtime-node': + specifier: 0.35.0 + version: 0.35.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-undici': + specifier: 0.32.0 + version: 0.32.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': + specifier: 2.11.0 + version: 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': + specifier: 0.222.0 + version: 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': + specifier: 2.11.0 + version: 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-node': + specifier: 0.222.0 + version: 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': + specifier: 2.11.0 + version: 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': + specifier: 1.43.0 + version: 1.43.0 + '@pyroscope/nodejs': + specifier: 0.6.4 + version: 0.6.4 + bullmq-otel: + specifier: 2.0.1 + version: 2.0.1(bullmq@6.3.8) + import-in-the-middle: + specifier: 3.5.1 + version: 3.5.1 + pino: + specifier: 10.3.1 + version: 10.3.1 + devDependencies: + '@opentelemetry/context-async-hooks': + specifier: 2.11.0 + version: 2.11.0(@opentelemetry/api@1.9.1) + '@types/node': + specifier: 22.19.17 + version: 22.19.17 + bullmq: + specifier: 6.3.8 + version: 6.3.8(bullmq-otel@2.0.1) + typescript: + specifier: 5.9.3 + version: 5.9.3 + vitest: + specifier: 3.2.7 + version: 3.2.7(@types/node@22.19.17)(yaml@2.9.1) + +packages: + + '@datadog/pprof@5.14.4': + resolution: {integrity: sha512-egEZDD9v98RBI8ijbHyaWQeY8rW0WEu004As5D7SUkdqSMORhrnh7ZdsM46PUzQgAc85IaEZoukWS9UhMvWn9w==} + engines: {node: '>=16'} + + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@grpc/grpc-js@1.14.6': + resolution: {integrity: sha512-SgC/B3/VZ8lTyOZ4Cwk6f1skWhmkNAaVfiX90qd5u1rRXGKk1//8FTUEN9GvOUq7rfWIqAYkA3CoyumYVpOS6g==} + engines: {node: '>=12.10.0'} + + '@grpc/proto-loader@0.8.1': + resolution: {integrity: sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==} + engines: {node: '>=6'} + hasBin: true + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@js-sdsl/ordered-map@4.4.2': + resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==} + + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': + resolution: {integrity: sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==} + cpu: [arm64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': + resolution: {integrity: sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==} + cpu: [x64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': + resolution: {integrity: sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==} + cpu: [arm64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': + resolution: {integrity: sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==} + cpu: [arm] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': + resolution: {integrity: sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==} + cpu: [x64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': + resolution: {integrity: sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==} + cpu: [x64] + os: [win32] + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@opentelemetry/api-logs@0.222.0': + resolution: {integrity: sha512-9mb1If+IF6u0ZVXkHQ6ogEae5HwA6ajIVUgpSDQyRASxft6BSXHvBvPooRle3yFN/fKnCdSOnuu0OC3PLcF6+g==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/api@1.9.1': + resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/configuration@0.222.0': + resolution: {integrity: sha512-zNWGmEvyX7CUbS6F6PKi7hf3CF/SffzeBYyWVQAfadZ8hODrvLmqV3IUGVt1SiqyYanRPHc8VuhoO0Qh9OEuug==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.9.0 + + '@opentelemetry/context-async-hooks@2.11.0': + resolution: {integrity: sha512-Tr79DyWI8itsBdg+jH+opjfrwLzX+erk1/ExkIwhWoAVjVrJIn2y5+cGjTC0Vy8fyNIA/y8wuJPZwr1T3xCZeQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/core@2.11.0': + resolution: {integrity: sha512-7YP44XH0tV6+Mb54x2YGf84i7yi+31MBZlE8JwvozkxyTvXbSp10X7cI7YE49ChJ3shMJoBmCJF3+1QFBJctGA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/exporter-logs-otlp-grpc@0.222.0': + resolution: {integrity: sha512-HH4Oi/E8HXBKtcjgcVPJGsrg+lSstdw1m93brRdpVj5CjMpyTkv/O0ZYMXP6QXVEaLjjFivtzUu4Y8+i1UJpjw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-logs-otlp-http@0.222.0': + resolution: {integrity: sha512-GqwLZohJTf/sttYlOpT1oFTJ9ScYaKnk0sOx4Qe/+KmXYVOa+v94cfnkUkFKDuvTODWhCL50Hz3gvsZD6ngQpg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-logs-otlp-proto@0.222.0': + resolution: {integrity: sha512-LlCDJuaznfFPzjHoKXjN/r9T5pgCZpmVheZ/EmwMUhMvpxI7RXDmsdLXoYpl5suYQNkwyXAUZhVEXKe3lev8Hg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-metrics-otlp-grpc@0.222.0': + resolution: {integrity: sha512-nPU2gpQWJfQttf+A71aLkKEOQoO6n6QGs5QTtPagi/rb3Lc1I55bqRXoLQEZsWqWimc8eas2Vo168NkTaByxQw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-metrics-otlp-http@0.222.0': + resolution: {integrity: sha512-6Ko+0bgNP6V4G/RsmNBSk5lO1B/lo9z6tvLpFY3ykPPevXNzFmOW3C9hEmTt0zKCvHovyrol3TYlg4HVb5gwjg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-metrics-otlp-proto@0.222.0': + resolution: {integrity: sha512-s1ZPwS8Ig7cpRmL3kCobPaFbemM81dJ03LNPrsuN32h3mCDvNF2NQ1pRHruOju0P0o1TZ24LkZA685rtQal1KA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-prometheus@0.222.0': + resolution: {integrity: sha512-I7nalnnBZrhlTgjlqsVeEjJGcQCFK5kKAYVk7XTz/cbubvj5fvjLOal/Many0taHF2GIkOWmegsbQ8vbQJjhFg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-trace-otlp-grpc@0.222.0': + resolution: {integrity: sha512-uisilePEdOMa3hIfXW82XXU6FofF8gw3iv+PoboIMWEUBC7XbJB3XnIHSdrSDsqCw3njR/If08qD0yzOY1U9Sg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-trace-otlp-http@0.222.0': + resolution: {integrity: sha512-RCnPWcHppwiquQ+cV3nWvNwdf0MG1w26e5jewW2T83nTZOlXgcg88sY9ulCVgagGHcq1mj0L0GP6YHgzk2v8oA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-trace-otlp-proto@0.222.0': + resolution: {integrity: sha512-imLrLzNhnABJt3tUyT63h6MqfZrG3Gdv3TE7+OUQTvwD088RfCUx3rF01lcgfu2KXBFk13vwi0yKZLvFEalkIA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-zipkin@2.11.0': + resolution: {integrity: sha512-mFibA45yXLEETIj+VcTVycFFe4R91iD41d+r3CU0f3DDWJCrICiHxUIjmYBTCZIuywXEeNo++2lncQeWnH7lWQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/instrumentation-http@0.222.0': + resolution: {integrity: sha512-HhMHW32LyGzIJEmUElxaML4oUe6JiCq0Hvjt/Awxb60SlMsSFetFLFjed45g/0wrPr1n7z+xW+x1B/584WBAkg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-ioredis@0.70.0': + resolution: {integrity: sha512-AUyn8K5vlSGkIF2AXPlUsa5d5ZXRidHMXlAHNAykvgW3u8juDZqGTgsOKJMYnLfllawygawLrqK6Fr2lnPcBUQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-pg@0.74.0': + resolution: {integrity: sha512-8i59Z7M0FjEwp2hF1eRqEr+NZtiwjvPYGl2xsGWEfL/hF6ffmPERWbySyK1EXa7gGyHCArwNkHLShsMfFywqpQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-runtime-node@0.35.0': + resolution: {integrity: sha512-/cOW4Qlv4nnr4QXDCkv0TEtOBa9R9lXHu//JuD5+nRPYR74g9iLmf5tadcYvn32oz2R8vQnV8FTnMLzjfAGMCQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-undici@0.32.0': + resolution: {integrity: sha512-Ms8oPkrCHa6hJOYodcFC8QOt1AFlns3KD1CqXBEihXyTFeLXDO84tIXYyKlXNHr8jMrl1Gm2velDvsSDXAuh9w==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.7.0 + + '@opentelemetry/instrumentation@0.222.0': + resolution: {integrity: sha512-fhbRDuAgzPKpq1k5+hX3uS+3QAYNca0on0Ngot/R8dDsQtuFaADeZnG2uSgxABuvcI7S2phAaRhbct9jieeKZw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-exporter-base@0.222.0': + resolution: {integrity: sha512-YbywG3veEm2Fb6TbdxRkuquWob6eVWXuA8/Ba1tXz9jHfUqpdE3keilOHEtPboC4CvS1bjeeVfNkWGOOrLj+lw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-grpc-exporter-base@0.222.0': + resolution: {integrity: sha512-Kw5WqDBYYpCgY+edHWZS/Jp3bdguqNkCyEWOsqFhf8RmtcEuWYzPFA9i+L6UsNoJXTs8ngeQB6dwELH1czNYjA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-transformer@0.222.0': + resolution: {integrity: sha512-/F3BZ89+CJQnZkMh2tCrtcdB+XT2Dxhj4FFE+WPQ//413hmFL0/RfEX6vgOIWGhiSzrkHWTK3+6SiT7K5/g/jQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/propagator-b3@2.11.0': + resolution: {integrity: sha512-ldyRZXx6qsqu9Spqd7rVxo6SNAUJqP8Ir3vwIi4N8xyZLXNl/2XeQmIHvFneiVOqNwWkU+JGFg2O9/T1fefU0w==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/propagator-jaeger@2.11.0': + resolution: {integrity: sha512-RUOqgoIqXOPQTOhpBrs07Y8sBlxVoLY5Fka6ueZ0prW+k6fWQsba/nHDY4gm+gmfuwtRqbX5Y9YKhDvktjbIqQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/redis-common@0.38.3': + resolution: {integrity: sha512-VCghU1JYs/4gP6Gqf/xro9MEsZ7LrMv2uONVsaESKL38ZOB9BqnI98FfS23wjMnHlpuE+TTaWSoAVNpTwYXzjw==} + engines: {node: ^18.19.0 || >=20.6.0} + + '@opentelemetry/resources@2.11.0': + resolution: {integrity: sha512-Ie7+8q8MDF4FAEQCKVMTx3ReUvxiIAgIiiW3c9JdmP8+HMcDy20puT+AHjexnExgnbvBxjQ9fjkFDWrikJ2jQA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-logs@0.222.0': + resolution: {integrity: sha512-+19YHODIjaUCArxleaJtuufFZVpz/xvvK+VllQqE+W8hHolxdoRwHfK/s667zezwh1hkx6FFF+oYzetYgqK+Bg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.4.0 <1.10.0' + + '@opentelemetry/sdk-metrics@2.11.0': + resolution: {integrity: sha512-7GXXcObyHyDUUSG+L+kJoquty01bzm7ivE7+SSgXXJcHuPzGviptxwARmI2c+bnnxjexGQbJnyNlN8HxBP/Y7A==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.9.0 <1.10.0' + + '@opentelemetry/sdk-node@0.222.0': + resolution: {integrity: sha512-t8tlFN/ezYV7iI4k6u3t2jFbIReX99Z0iVMVSYLJscE4McxIW9YGHhdOldeY1elHeoq8mjEO4B9s4Fk7+zJbsg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace-base@2.11.0': + resolution: {integrity: sha512-H19x/TX/LZdqiYOjM7fqtSxwlplC5pgelavqbQdHbhdq0q/AI/TGkM2dfGuuynTXmJPeF2HoZVoPDu+TGoW78A==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace-node@2.11.0': + resolution: {integrity: sha512-CuvCMJmZxswhNLlM2LfuLOW3h3fZujA4hsG4B+Sz4dX2zvaXO8Ng74cnDHWD64gLszTlhiG3c0iNUjj4g+0/sA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/sdk-trace@2.11.0': + resolution: {integrity: sha512-fFnTqGm8/G73GQVnxYi7LXa1ZVYEUvgL6XI1LpvV0bPC7WQ/ZGgKxCSl8FnlZBKto9JHHEFTO6s6CUpvvtwFrA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/semantic-conventions@1.43.0': + resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==} + engines: {node: '>=14'} + + '@opentelemetry/sql-common@0.42.0': + resolution: {integrity: sha512-nwUwUU+8O8a4bnLqk6CodWeegGMEANgC94KTAhXcpGWLrW/2/hek/0ajNbjXnSOoNuCX+nteUPs46HFHhou9Xw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.1.0 + + '@pinojs/redact@0.4.0': + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.5': + resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==} + + '@protobufjs/eventemitter@1.1.1': + resolution: {integrity: sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==} + + '@protobufjs/fetch@1.1.1': + resolution: {integrity: sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.2': + resolution: {integrity: sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==} + + '@pyroscope/nodejs@0.6.4': + resolution: {integrity: sha512-pSRR7QOtToxhYuKJJRxFHR74pElI2jSFV20b/j+zN3BMEoOOUCA/RxgEK3gjGB66/c85RGeRy5emOIf6+OAghw==} + engines: {node: '>=20.20.2'} + peerDependencies: + express: ^4.0.0 || ^5.0.0 + fastify: ^5.8.5 + peerDependenciesMeta: + express: + optional: true + fastify: + optional: true + + '@rollup/rollup-android-arm-eabi@4.64.5': + resolution: {integrity: sha512-MrfgRQSpD5hkkRg6XI16B6EDLb+6+xp8xje6bpdSwkYw+qxrBKbUVrRAQu9h82UT+QumSIVQ8HXsXjPd2dlhqw==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.64.5': + resolution: {integrity: sha512-+uu2p/R9Tzn8GpoQyX1pAo5dmKT1DhXevfaCzqzmPFhnLjHLWw7uWkaVGlxmhwYoRiYYhsGfRcD5cTBpL7rgSw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.64.5': + resolution: {integrity: sha512-17dys+YEmzwzzFVHQAVDONg5FALXY9kd99HFEpfk4gJVHYxoh5bGea3D4t1fXXnwkTcOe25wPyd6nWS0I8IdLQ==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.64.5': + resolution: {integrity: sha512-wg0zQEKkEvFZf1UaaX1UELOoPs//OSKUG7D+yITuqOsAvza7LvHTv5Wu4nmMfzNG7EYI2jp2PJC7Id6OETmsYQ==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.64.5': + resolution: {integrity: sha512-CoJmWcpEMuwin+TZ/ev/H9uDyF/uxUKQSWXdmIPn/UBx/uGV9+4ebMp0FwnlhWR9PE1AdzYsKf3bZevhif38+A==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.64.5': + resolution: {integrity: sha512-PxZnLAaTUAL5xG0W2BXLeYPKrK2b3LIASV2/e/Kf7PZvSLI1iAQIm9B9aJD+YyFIT7hc6qaMV3+OOO35R28LfA==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.64.5': + resolution: {integrity: sha512-uPo3IvnONjhbFXwWgroBeFwOicuhT7ZwQrnzkzmOtBJzeQbkUJUE1SJDfDI31ijQ5+ZUIA6sPrdUMxTSnxUWAg==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.64.5': + resolution: {integrity: sha512-ywI8Jyuc4LyHYTjC0mw4yA1ulvZBkHusdpWnoiEBsmBuHvSnt90btTBGCZ+s/2y+YNNfVAEKy33nPo5UI9zTmg==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.64.5': + resolution: {integrity: sha512-tmuXJoQ/3ovSr637aykZMdQteU/CB2APAz2CrvsbibuoHZi1BS1Db13cVViBdEARGwXCLn1DSJ8wsjoThLJPGA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.64.5': + resolution: {integrity: sha512-5FMjSYNcUOHx7oW8Qu9KB6jGPpvF/8sG7+BDHwU/Ihh1/6FMJ9ySmsFG4tJ1qbNRuP8OLzCBDso4sF0mQaLuFQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.64.5': + resolution: {integrity: sha512-K8ZJ+neLDbUkIrL8dqF/5LtuVCrKwo27kREgOlxjBEjlOXwHvDnHLU8Z/UKv/B49qi7e8hsSDOyoJwhwqhvX4w==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.64.5': + resolution: {integrity: sha512-NlVxsq7PFPv0DBKGuNWRDKixQYQCjM53zl04mxEcKMBmJbCBIUm1JdPfIWDRHyOOFzzYPmLQzfeEwh4716ayQg==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.64.5': + resolution: {integrity: sha512-ARhRHJYs5eCFet5azfrHD97vOkD8OzNW2RSMlvo5CcBqXaTOLfp0OHDtUQh7/K5AL5fUYoFp2fgC9KDg4vVYMQ==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.64.5': + resolution: {integrity: sha512-atSgreM5C7jpxd9aEXN+6vJ5wTd05iYw+S3FiQpw7VsoEl8B90llbq35ARON+GOHj5QlVQEq2vOKWCHTob/BSQ==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.64.5': + resolution: {integrity: sha512-vmlmwBFGnjVWxSRoVabNkyYuscSmg52Pd0fM8jEbNT3Jmka4A6HcB84x4uAmuumznWfBVaxKOqU6jbEgomWKCA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.64.5': + resolution: {integrity: sha512-hhJrP6S9WkbsbcHgUkvCBS5Cn8IdsG8XjlLZyxjnv9+NdoYfbbZE4TWJYr8apdaj1M5t5Y1id2StnEslxpNFyA==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.64.5': + resolution: {integrity: sha512-NNVCyN9pGrwFmQa4bf+Cnf5aVH3g6ZpnlOg1Cs/H9swIHT9bRixijfzUqyEIWm1WtiAuR1LJ+rJH6zpBm8kgQA==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.64.5': + resolution: {integrity: sha512-dR9nd6OKnvSS60Ifz/L7Mj5zzYyoqyRn2NwTuaOONKaa3xsVGXyp8YusMbvFyEOR94DxnwgcbgsFqkHd29ZSzQ==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.64.5': + resolution: {integrity: sha512-1tzsMjaqhsh3bAO6SJiJqh8hIr9sSPRd+i2vPPxJHod8/XYnf9TEnK0z/u3r1z1AkI347X0KbRSg9dWcxeGahw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.64.5': + resolution: {integrity: sha512-01mWMQblKoXXsmk28KXtpbA96w7g/aOWUoV3AC16fdHdqkykGalqyyM+5u8M7uP5/MEMiiPv69Hv7iv/hEH/dA==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.64.5': + resolution: {integrity: sha512-trdnMPb/DNm4V/CgsaoGgr90HfQQ071wfK0KoIs4YpLvjBgVQGXhGabg2Z6nK4EcYNilrb9ZZPcsP6+zpJIdxQ==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.64.5': + resolution: {integrity: sha512-x2LxZlfdfsw99T6/qb9EqE/Qi84JWzibjyOJyhJsgFJy6CafaTiqIXCCnslCGrV5XWNh7fwrS3FbRENcuKnooA==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.64.5': + resolution: {integrity: sha512-/Qd/9aDQIo0+jmItgBmPIs8195YVbDxRRApn47GyufgbutCDOh7eP5vSKKlI4QNFeaI+wKQAkSMsPnF3EuMpaA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.64.5': + resolution: {integrity: sha512-ngUJ/XvOWrqbd2IMsfXfh2xXFLTP3sY7sCKvxbGvYdBZ1KmXFd3ewDZ9n3/GNzAUgzRuhLT/UvJRivbNoAQRBw==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.64.5': + resolution: {integrity: sha512-IhV6ew/DuzUWwHaYmlh7MrPCV3lKFUq/amTEwysi4r/mwNPAEfAMh5dYWeTSPTeMy2pFHGMEWYtPkHzfX3tG5A==} + cpu: [x64] + os: [win32] + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/node@22.19.17': + resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==} + + '@types/pg-pool@2.0.7': + resolution: {integrity: sha512-U4CwmGVQcbEuqpyju8/ptOKg6gEC+Tqsvj2xS9o1g71bUh8twxnC6ZL5rZKCsGN0iyH0CwgUyc9VR5owNQF9Ng==} + + '@types/pg@8.15.6': + resolution: {integrity: sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==} + + '@vitest/expect@3.2.7': + resolution: {integrity: sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==} + + '@vitest/mocker@3.2.7': + resolution: {integrity: sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.7': + resolution: {integrity: sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==} + + '@vitest/runner@3.2.7': + resolution: {integrity: sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==} + + '@vitest/snapshot@3.2.7': + resolution: {integrity: sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==} + + '@vitest/spy@3.2.7': + resolution: {integrity: sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==} + + '@vitest/utils@3.2.7': + resolution: {integrity: sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + + bullmq-otel@2.0.1: + resolution: {integrity: sha512-w+nO+oo3VBL62z2oVlqIPgc2g5+CNy6O3FQEy+M/220togI/NBaqanGdTQt0pvzE8o++oz5D+GtROuRqD9+8qg==} + peerDependencies: + bullmq: '>=6.0.0' + + bullmq@6.3.8: + resolution: {integrity: sha512-09zcOQ1uycyCK2C9iY2Pm+/JPa/gIXiZqGBUiIlO1v3VshwBgarmAgWRTWDL/IAvI8NFE1zqrkdKFgRfXHHM5A==} + engines: {node: '>=14.17.0'} + peerDependencies: + bullmq-otel: '>=2.0.0' + ioredis: '>=5.0.0' + pg: '>=8.0.0' + redis: '>=5.0.0' + peerDependenciesMeta: + bullmq-otel: + optional: true + ioredis: + optional: true + pg: + optional: true + redis: + optional: true + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + cjs-module-lexer@2.3.0: + resolution: {integrity: sha512-lmNyBzi6iYiqyrExTctfmjd8tInx+dHu9PBomTfH5RXS53c27JMFJgzNv5TyAnm3PgGf+MLy0+Ws41/HlVxdKQ==} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + cron-parser@5.10.1: + resolution: {integrity: sha512-pKRrRagItwk9rGIStcUyMxFX34x56zoX3KDf9HJ4ttwkXIK1qxK63EvXvEDmlxI9AdPJ+Y7TEKRftRlW5eSS7A==} + engines: {node: '>=18'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + es-module-lexer@3.0.3: + resolution: {integrity: sha512-yWGe1O/j+almnV4hOYrmZo8u58JdnmXEMneRQAYkWtqSiT86yoMIEKF4rpqEDj52tbYl3xceBD3DQIBwcsC9Pw==} + + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + forwarded-parse@2.1.2: + resolution: {integrity: sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + import-in-the-middle@3.5.1: + resolution: {integrity: sha512-mPKuL8bPQzecui2KK6Gb+M8JvJoHnhS1FeYGa22QopBmlevF5F0FE6ued/B5EgHDeIoMTONIpDWWFKUPOG0DBQ==} + engines: {node: '>=18'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + lodash.camelcase@4.3.0: + resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + luxon@3.7.2: + resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} + engines: {node: '>=12'} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + module-details-from-path@1.0.4: + resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + msgpackr-extract@3.0.4: + resolution: {integrity: sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==} + hasBin: true + + msgpackr@2.1.0: + resolution: {integrity: sha512-p/pBCVO63CsvvpkomUnNNag6+n38rULuDA6HHe70o2gtC8ODI52foF/4ko2qQcp6OiErJXTmrZeXmsGGHsIQNQ==} + + nanoid@3.3.20: + resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-abort-controller@3.1.1: + resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==} + + node-gyp-build-optional-packages@5.2.2: + resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} + hasBin: true + + node-gyp-build@4.8.4: + resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} + hasBin: true + + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + + p-limit@7.3.3: + resolution: {integrity: sha512-SKgVvDU5TNIxQ4r30U75BNLC6a+6GOEjngrk/ysM5+Zu3oJuk1SC5ApzIzY/7PjCEjoVPMWJ0zho/4QUiEB7TQ==} + engines: {node: '>=20'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-protocol@1.16.1: + resolution: {integrity: sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + pino-abstract-transport@3.0.0: + resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} + + pino-std-serializers@7.1.0: + resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} + + pino@10.3.1: + resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} + hasBin: true + + postcss@8.5.30: + resolution: {integrity: sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==} + engines: {node: ^10 || ^12 || >=14} + + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + + pprof-format@2.3.2: + resolution: {integrity: sha512-fcMM+sgKMOveSurz7i0BMHfy1hHNnI9+VWjd4kdclqQjSTuiHWaP69DGwvkw3zcLkM/F76LhVIKvPyrxmp6iiw==} + + process-warning@5.1.1: + resolution: {integrity: sha512-oCkNVj4OKnKEcocVBcDfiIugN1/cE6rgfT3nCtzeqZPA8+rIxMaQ0xXzIkGP8Gj2qUaP1UR7uhJ1VD8lUCpzCg==} + + protobufjs@7.6.6: + resolution: {integrity: sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==} + engines: {node: '>=12.0.0'} + + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + + real-require@1.0.0: + resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==} + + regenerator-runtime@0.14.1: + resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-in-the-middle@8.0.1: + resolution: {integrity: sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==} + engines: {node: '>=9.3.0 || >=8.10.0 <9.0.0'} + + rollup@4.64.5: + resolution: {integrity: sha512-EAP5ZHcz2ijbgCWwPkpI7zh6bfsri2Ez5FtCy2dCp1k62duXzfG7DLGGh7RU2QQXWwavT+u4qpvDF/TWOiiGTw==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + sonic-boom@4.2.1: + resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} + + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} + engines: {node: '>=0.10.0'} + + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + + thread-stream@4.2.0: + resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==} + engines: {node: '>=20'} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.6: + resolution: {integrity: sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==} + engines: {node: '>=14.0.0'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite@7.3.7: + resolution: {integrity: sha512-AHEzTORMbi7/7aXp7w7KZ5+DCUdSeLsJz0grY8xHpc9DHdYb0vQu6DqJfm2tOw3gDExNgPgnJTXxM6syYFHaCA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@3.2.7: + resolution: {integrity: sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.7 + '@vitest/ui': 3.2.7 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yaml@2.9.1: + resolution: {integrity: sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==} + engines: {node: '>= 14.6'} + hasBin: true + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.3: + resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} + engines: {node: '>=12'} + + yocto-queue@1.2.2: + resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} + engines: {node: '>=12.20'} + +snapshots: + + '@datadog/pprof@5.14.4': + dependencies: + node-gyp-build: 4.8.4 + pprof-format: 2.3.2 + source-map: 0.7.6 + + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + + '@grpc/grpc-js@1.14.6': + dependencies: + '@grpc/proto-loader': 0.8.1 + '@js-sdsl/ordered-map': 4.4.2 + + '@grpc/proto-loader@0.8.1': + dependencies: + lodash.camelcase: 4.3.0 + long: 5.3.2 + protobufjs: 7.6.6 + yargs: 17.7.3 + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@js-sdsl/ordered-map@4.4.2': {} + + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': + optional: true + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + optional: true + + '@opentelemetry/api-logs@0.222.0': + dependencies: + '@opentelemetry/api': 1.9.1 + + '@opentelemetry/api@1.9.1': {} + + '@opentelemetry/configuration@0.222.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + yaml: 2.9.1 + + '@opentelemetry/context-async-hooks@2.11.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + + '@opentelemetry/core@2.11.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/exporter-logs-otlp-grpc@0.222.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/otlp-exporter-base': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-grpc-exporter-base': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.222.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-logs-otlp-http@0.222.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/otlp-exporter-base': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.222.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-logs-otlp-proto@0.222.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/otlp-exporter-base': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.222.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-metrics-otlp-grpc@0.222.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/exporter-metrics-otlp-http': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-grpc-exporter-base': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.222.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-metrics-otlp-http@0.222.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.11.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-metrics-otlp-proto@0.222.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/exporter-metrics-otlp-http': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.222.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-prometheus@0.222.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/exporter-trace-otlp-grpc@0.222.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/otlp-exporter-base': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-grpc-exporter-base': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace': 2.11.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-trace-otlp-http@0.222.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/otlp-exporter-base': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace': 2.11.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-trace-otlp-proto@0.222.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/otlp-exporter-base': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace': 2.11.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-zipkin@2.11.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/instrumentation-http@0.222.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + forwarded-parse: 2.1.2 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-ioredis@0.70.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/redis-common': 0.38.3 + '@opentelemetry/semantic-conventions': 1.43.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-pg@0.74.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + '@opentelemetry/sql-common': 0.42.0(@opentelemetry/api@1.9.1) + '@types/pg': 8.15.6 + '@types/pg-pool': 2.0.7 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-runtime-node@0.35.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.222.0 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.222.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-undici@0.32.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation@0.222.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.222.0 + import-in-the-middle: 3.5.1 + require-in-the-middle: 8.0.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/otlp-exporter-base@0.222.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.222.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/otlp-grpc-exporter-base@0.222.0(@opentelemetry/api@1.9.1)': + dependencies: + '@grpc/grpc-js': 1.14.6 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.222.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/otlp-transformer@0.222.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.222.0 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace': 2.11.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/propagator-b3@2.11.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/propagator-jaeger@2.11.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/redis-common@0.38.3': {} + + '@opentelemetry/resources@2.11.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/sdk-logs@0.222.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.222.0 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/sdk-metrics@2.11.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.11.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/sdk-node@0.222.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.222.0 + '@opentelemetry/configuration': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/context-async-hooks': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-logs-otlp-grpc': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-logs-otlp-http': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-logs-otlp-proto': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-grpc': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-http': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-proto': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-prometheus': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-grpc': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-http': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-proto': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-zipkin': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-grpc-exporter-base': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/propagator-b3': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/propagator-jaeger': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-node': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/sdk-trace-base@2.11.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/sdk-trace-node@2.11.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/context-async-hooks': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.11.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/sdk-trace@2.11.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/semantic-conventions@1.43.0': {} + + '@opentelemetry/sql-common@0.42.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + + '@pinojs/redact@0.4.0': {} + + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.5': {} + + '@protobufjs/eventemitter@1.1.1': {} + + '@protobufjs/fetch@1.1.1': + dependencies: + '@protobufjs/aspromise': 1.1.2 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.2': {} + + '@pyroscope/nodejs@0.6.4': + dependencies: + '@datadog/pprof': 5.14.4 + debug: 4.4.3 + p-limit: 7.3.3 + pprof-format: 2.3.2 + regenerator-runtime: 0.14.1 + source-map: 0.7.6 + transitivePeerDependencies: + - supports-color + + '@rollup/rollup-android-arm-eabi@4.64.5': + optional: true + + '@rollup/rollup-android-arm64@4.64.5': + optional: true + + '@rollup/rollup-darwin-arm64@4.64.5': + optional: true + + '@rollup/rollup-darwin-x64@4.64.5': + optional: true + + '@rollup/rollup-freebsd-arm64@4.64.5': + optional: true + + '@rollup/rollup-freebsd-x64@4.64.5': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.64.5': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.64.5': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.64.5': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.64.5': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.64.5': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.64.5': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.64.5': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.64.5': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.64.5': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.64.5': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.64.5': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.64.5': + optional: true + + '@rollup/rollup-linux-x64-musl@4.64.5': + optional: true + + '@rollup/rollup-openbsd-x64@4.64.5': + optional: true + + '@rollup/rollup-openharmony-arm64@4.64.5': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.64.5': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.64.5': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.64.5': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.64.5': + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/node@22.19.17': + dependencies: + undici-types: 6.21.0 + + '@types/pg-pool@2.0.7': + dependencies: + '@types/pg': 8.15.6 + + '@types/pg@8.15.6': + dependencies: + '@types/node': 22.19.17 + pg-protocol: 1.16.1 + pg-types: 2.2.0 + + '@vitest/expect@3.2.7': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.7(vite@7.3.7(@types/node@22.19.17)(yaml@2.9.1))': + dependencies: + '@vitest/spy': 3.2.7 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.7(@types/node@22.19.17)(yaml@2.9.1) + + '@vitest/pretty-format@3.2.7': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.7': + dependencies: + '@vitest/utils': 3.2.7 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.2.7': + dependencies: + tinyspy: 4.0.6 + + '@vitest/utils@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + assertion-error@2.0.1: {} + + atomic-sleep@1.0.0: {} + + bullmq-otel@2.0.1(bullmq@6.3.8): + dependencies: + '@opentelemetry/api': 1.9.1 + bullmq: 6.3.8(bullmq-otel@2.0.1) + tslib: 2.8.1 + + bullmq@6.3.8(bullmq-otel@2.0.1): + dependencies: + cron-parser: 5.10.1 + msgpackr: 2.1.0 + node-abort-controller: 3.1.1 + semver: 7.8.5 + tslib: 2.8.1 + optionalDependencies: + bullmq-otel: 2.0.1(bullmq@6.3.8) + + cac@6.7.14: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + check-error@2.1.3: {} + + cjs-module-lexer@2.3.0: {} + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + cron-parser@5.10.1: + dependencies: + luxon: 3.7.2 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-eql@5.0.2: {} + + detect-libc@2.1.2: + optional: true + + emoji-regex@8.0.0: {} + + es-module-lexer@1.7.0: {} + + es-module-lexer@3.0.3: {} + + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + + escalade@3.2.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.4.0: {} + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + forwarded-parse@2.1.2: {} + + fsevents@2.3.3: + optional: true + + get-caller-file@2.0.5: {} + + import-in-the-middle@3.5.1: + dependencies: + cjs-module-lexer: 2.3.0 + es-module-lexer: 3.0.3 + module-details-from-path: 1.0.4 + + is-fullwidth-code-point@3.0.0: {} + + js-tokens@9.0.1: {} + + lodash.camelcase@4.3.0: {} + + long@5.3.2: {} + + loupe@3.2.1: {} + + luxon@3.7.2: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + module-details-from-path@1.0.4: {} + + ms@2.1.3: {} + + msgpackr-extract@3.0.4: + dependencies: + node-gyp-build-optional-packages: 5.2.2 + optionalDependencies: + '@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.4 + optional: true + + msgpackr@2.1.0: + optionalDependencies: + msgpackr-extract: 3.0.4 + + nanoid@3.3.20: {} + + node-abort-controller@3.1.1: {} + + node-gyp-build-optional-packages@5.2.2: + dependencies: + detect-libc: 2.1.2 + optional: true + + node-gyp-build@4.8.4: {} + + on-exit-leak-free@2.1.2: {} + + p-limit@7.3.3: + dependencies: + yocto-queue: 1.2.2 + + pathe@2.0.3: {} + + pathval@2.0.1: {} + + pg-int8@1.0.1: {} + + pg-protocol@1.16.1: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + picocolors@1.1.1: {} + + picomatch@4.0.7: {} + + pino-abstract-transport@3.0.0: + dependencies: + split2: 4.2.0 + + pino-std-serializers@7.1.0: {} + + pino@10.3.1: + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pino-std-serializers: 7.1.0 + process-warning: 5.1.1 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.1 + thread-stream: 4.2.0 + + postcss@8.5.30: + dependencies: + nanoid: 3.3.20 + picocolors: 1.1.1 + source-map-js: 1.2.2 + + postgres-array@2.0.0: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + + pprof-format@2.3.2: {} + + process-warning@5.1.1: {} + + protobufjs@7.6.6: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.5 + '@protobufjs/eventemitter': 1.1.1 + '@protobufjs/fetch': 1.1.1 + '@protobufjs/float': 1.0.2 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.2 + '@types/node': 22.19.17 + long: 5.3.2 + + quick-format-unescaped@4.0.4: {} + + real-require@0.2.0: {} + + real-require@1.0.0: {} + + regenerator-runtime@0.14.1: {} + + require-directory@2.1.1: {} + + require-in-the-middle@8.0.1: + dependencies: + debug: 4.4.3 + module-details-from-path: 1.0.4 + transitivePeerDependencies: + - supports-color + + rollup@4.64.5: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@napi-rs/lzma-linux-x64-gnu': 1.5.1 + '@rollup/rollup-android-arm-eabi': 4.64.5 + '@rollup/rollup-android-arm64': 4.64.5 + '@rollup/rollup-darwin-arm64': 4.64.5 + '@rollup/rollup-darwin-x64': 4.64.5 + '@rollup/rollup-freebsd-arm64': 4.64.5 + '@rollup/rollup-freebsd-x64': 4.64.5 + '@rollup/rollup-linux-arm-gnueabihf': 4.64.5 + '@rollup/rollup-linux-arm-musleabihf': 4.64.5 + '@rollup/rollup-linux-arm64-gnu': 4.64.5 + '@rollup/rollup-linux-arm64-musl': 4.64.5 + '@rollup/rollup-linux-loong64-gnu': 4.64.5 + '@rollup/rollup-linux-loong64-musl': 4.64.5 + '@rollup/rollup-linux-ppc64-gnu': 4.64.5 + '@rollup/rollup-linux-ppc64-musl': 4.64.5 + '@rollup/rollup-linux-riscv64-gnu': 4.64.5 + '@rollup/rollup-linux-riscv64-musl': 4.64.5 + '@rollup/rollup-linux-s390x-gnu': 4.64.5 + '@rollup/rollup-linux-x64-gnu': 4.64.5 + '@rollup/rollup-linux-x64-musl': 4.64.5 + '@rollup/rollup-openbsd-x64': 4.64.5 + '@rollup/rollup-openharmony-arm64': 4.64.5 + '@rollup/rollup-win32-arm64-msvc': 4.64.5 + '@rollup/rollup-win32-ia32-msvc': 4.64.5 + '@rollup/rollup-win32-x64-gnu': 4.64.5 + '@rollup/rollup-win32-x64-msvc': 4.64.5 + fsevents: 2.3.3 + + safe-stable-stringify@2.5.0: {} + + semver@7.8.5: {} + + siginfo@2.0.0: {} + + sonic-boom@4.2.1: + dependencies: + atomic-sleep: 1.0.0 + + source-map-js@1.2.2: {} + + source-map@0.7.6: {} + + split2@4.2.0: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + + thread-stream@4.2.0: + dependencies: + real-require: 1.0.0 + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.6: {} + + tslib@2.8.1: {} + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + vite-node@3.2.4(@types/node@22.19.17)(yaml@2.9.1): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.7(@types/node@22.19.17)(yaml@2.9.1) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite@7.3.7(@types/node@22.19.17)(yaml@2.9.1): + dependencies: + esbuild: 0.28.2 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + postcss: 8.5.30 + rollup: 4.64.5 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 22.19.17 + fsevents: 2.3.3 + yaml: 2.9.1 + + vitest@3.2.7(@types/node@22.19.17)(yaml@2.9.1): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.7 + '@vitest/mocker': 3.2.7(vite@7.3.7(@types/node@22.19.17)(yaml@2.9.1)) + '@vitest/pretty-format': 3.2.7 + '@vitest/runner': 3.2.7 + '@vitest/snapshot': 3.2.7 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.4.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.7 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.17 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.7(@types/node@22.19.17)(yaml@2.9.1) + vite-node: 3.2.4(@types/node@22.19.17)(yaml@2.9.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.19.17 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + xtend@4.0.2: {} + + y18n@5.0.8: {} + + yaml@2.9.1: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.3: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yocto-queue@1.2.2: {} diff --git a/src/__tests__/bounded.test.ts b/src/__tests__/bounded.test.ts new file mode 100644 index 0000000..09fbcbb --- /dev/null +++ b/src/__tests__/bounded.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { boundedLabel } from "../bounded.js"; + +const PROVIDERS = ["stripe", "stripe_v2", "postgrid", "resend", "happyco", "funnel"] as const; + +/** A metric label is a closed set: anything outside the allowlist collapses into one value. */ +describe("boundedLabel", () => { + it("passes an allowlisted value through", () => { + expect(boundedLabel("stripe", PROVIDERS)).toBe("stripe"); + expect(boundedLabel("funnel", new Set(PROVIDERS))).toBe("funnel"); + }); + + it.each([ + ["a path traversal", "/v1/webhooks/../../x"], + ["an empty string", ""], + ["a 10 000-character string", "s".repeat(10_000)], + ["a near miss in case", "Stripe"], + ["an id", "3f2c1a9e-5b7d-4c11-9a0e-2d6f8b1c4e77"], + ])("maps %s to other", (_, value) => { + expect(boundedLabel(value, PROVIDERS)).toBe("other"); + }); + + it("maps a non-string to other", () => { + expect(boundedLabel(undefined, PROVIDERS)).toBe("other"); + expect(boundedLabel(42, PROVIDERS)).toBe("other"); + }); +}); diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts new file mode 100644 index 0000000..c2757ec --- /dev/null +++ b/src/__tests__/config.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { resolveTelemetryConfig } from "../config.js"; + +describe("resolveTelemetryConfig", () => { + it("is disabled when no endpoint is configured", () => { + expect(resolveTelemetryConfig("api", {}).enabled).toBe(false); + expect(resolveTelemetryConfig("api", { OTEL_EXPORTER_OTLP_ENDPOINT: " " }).enabled).toBe( + false, + ); + }); + it("is disabled by the standard kill switch even with an endpoint", () => { + const env = { OTEL_EXPORTER_OTLP_ENDPOINT: "http://c:4318", OTEL_SDK_DISABLED: "TRUE" }; + expect(resolveTelemetryConfig("api", env).enabled).toBe(false); + }); + it("names the service, version and environment from Railway", () => { + const config = resolveTelemetryConfig("api", { + OTEL_EXPORTER_OTLP_ENDPOINT: "http://otel-collector.railway.internal:4318", + RAILWAY_GIT_COMMIT_SHA: "abc123", + RAILWAY_ENVIRONMENT_NAME: " cody-dev-env ", + RAILWAY_REPLICA_ID: " 7f3c-replica ", + }); + expect(config).toEqual({ + enabled: true, + serviceName: "api", + serviceVersion: "abc123", + environment: "cody-dev-env", + serviceInstanceId: "7f3c-replica", + }); + }); + it("prefers OTEL_SERVICE_NAME and falls back to unknown", () => { + const config = resolveTelemetryConfig("api", { OTEL_SERVICE_NAME: "api-canary" }); + expect(config.serviceName).toBe("api-canary"); + expect(config.serviceVersion).toBe("unknown"); + expect(resolveTelemetryConfig("api", { RAILWAY_DEPLOYMENT_ID: "dep-1" }).serviceVersion).toBe( + "dep-1", + ); + expect(config.environment).toBe("unknown"); + }); + it("falls back to one random instance id per process", () => { + const first = resolveTelemetryConfig("api", {}).serviceInstanceId; + expect(first).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); + expect(resolveTelemetryConfig("worker", { RAILWAY_REPLICA_ID: " " }).serviceInstanceId).toBe( + first, + ); + }); +}); diff --git a/src/__tests__/console-capture.test.ts b/src/__tests__/console-capture.test.ts new file mode 100644 index 0000000..578d410 --- /dev/null +++ b/src/__tests__/console-capture.test.ts @@ -0,0 +1,211 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { inspect } from "node:util"; +import { logs } from "@opentelemetry/api-logs"; +import { captureConsole, releaseConsole } from "../console-capture.js"; +import { log } from "../shared-log.js"; +import { bootWithMemoryLogs, exported, flushLogs, stopTelemetry } from "./otlp-export-helpers.js"; + +vi.mock("@opentelemetry/exporter-logs-otlp-proto", async () => + (await import("./otlp-export-helpers.js")).memoryLogExporterModule(), +); + +const written = { warn: vi.fn(), error: vi.fn() }; +const original = { warn: console.warn, error: console.error }; + +beforeAll(async () => { + console.warn = written.warn; + console.error = written.error; + // No logScope: the scope defaults to the service name. + await bootWithMemoryLogs({ serviceName: "acp-api" }); + captureConsole(); + // The boot's own telemetry_started line. + await flushLogs(); +}); + +afterAll(async () => { + releaseConsole(); + console.warn = original.warn; + console.error = original.error; + await stopTelemetry(); +}); + +beforeEach(() => { + exported.length = 0; + written.warn.mockClear(); + written.error.mockClear(); +}); + +function bodies(): unknown[] { + return exported.map((record) => record.body); +} + +describe("captureConsole", () => { + // WHY: an app that logs with console must reach Loki without a rewrite, and stdout keeps the + // line it always had. + it("still writes the console line, unchanged", async () => { + console.error("payout failed for", "acct"); + expect(written.error).toHaveBeenCalledWith("payout failed for", "acct"); + await flushLogs(); + expect(bodies()).toEqual(["payout failed for acct"]); + }); + + it("scrubs an SSN-shaped value before export", async () => { + console.warn("screening returned 123-45-6789 for applicant"); + const records = await flushLogs(); + expect(records).toHaveLength(1); + expect(JSON.stringify(records[0]!.body)).not.toContain("123-45-6789"); + expect(records[0]!.body).toBe("screening returned [redacted] for applicant"); + expect(records[0]!.severityText).toBe("warn"); + }); + + it("redacts a PII key in an object it is given", async () => { + console.error("lookup failed", { email: "jane@example.com", phone: "555-0100", queue: "q" }); + const [record] = await flushLogs(); + expect(record!.body).not.toMatch(/jane|555-0100/); + expect(record!.body).toContain("queue: 'q'"); + }); + + it("exports an Error under err, scrubbed", async () => { + console.error(new Error("card 4111111111111111 declined")); + const [record] = await flushLogs(); + expect(JSON.stringify(record)).not.toContain("4111111111111111"); + expect(record!.attributes.err).toMatchObject({ type: "Error" }); + }); + + // WHY: the exported line must read like the console line, so a Loki search finds it. + it("puts an Error's scrubbed message where a format directive takes it", async () => { + const err = new Error("charge for 123-45-6789 declined"); + console.error("charge failed: %s", err); + const [record] = await flushLogs(); + expect(record!.body).toBe("charge failed: charge for [redacted] declined"); + expect(record!.attributes.err).toMatchObject({ type: "Error" }); + expect(JSON.stringify(record)).not.toContain("123-45-6789"); + }); + + // WHY: arguments are sanitized one by one, so a value split across them only takes its shape + // once they are joined; the joined message is scrubbed again. + it("scrubs an SSN formed only once %s arguments are joined", async () => { + console.error("ssn %s-%s", "123-45", "6789"); + const [record] = await flushLogs(); + expect(JSON.stringify(record)).not.toContain("123-45-6789"); + expect(record!.body).toBe("ssn [redacted]"); + }); + + it("scrubs an account number formed only once arguments are joined", async () => { + console.error("refund to %s%s", "4111111", "111111111"); + const [record] = await flushLogs(); + expect(JSON.stringify(record)).not.toContain("4111111111111111"); + }); + + it("scrubs a second Error's message and stack", async () => { + console.error(new Error("first"), new Error("applicant 123-45-6789 failed")); + const [record] = await flushLogs(); + expect(JSON.stringify(record)).not.toContain("123-45-6789"); + }); + + // WHY: the logger's JSON shows a Map as {}, but the console format shows its entries. + it("redacts a PII key inside a Map, a Set and a class instance", async () => { + class Lookup { + readonly owner = new Map([["password", "hunter2"]]); + } + console.error( + "lookup failed", + new Map([["phone", "555-0100"]]), + new Set([{ ssn: "987654321" }]), + new Lookup(), + new Uint8Array([104, 117, 110]), + ); + const [record] = await flushLogs(); + const text = JSON.stringify(record); + expect(text).not.toMatch(/555-0100|987654321|hunter2/); + expect(text).not.toContain("104, 117, 110"); + }); + + // WHY: what is checked must be what is exported; a custom inspect, toString or toJSON renders + // text the field sanitizer never walked. + it("scrubs what custom inspect, toString and toJSON render", async () => { + const custom = { [inspect.custom]: () => "ssn 123-45-6789" }; + const stringy = { toString: () => "account 4111111111111111" }; + const json = { toJSON: () => ({ email: "jane@example.com", note: "123-45-6789" }) }; + console.error("a %s b %O c %j d", stringy, custom, json); + const [record] = await flushLogs(); + expect(JSON.stringify(record)).not.toMatch(/123-45-6789|4111111111111111|jane@example\.com/); + }); + + // WHY: console formatting prints a function's own properties and, under %s, its source; the + // logger's JSON drops functions. Console capture must never export more than `log` would. + it("exports no property of a function, top-level or nested", async () => { + const handler = Object.assign(function handler() { + return "source with hunter2"; + }, { owner: { firstName: "Zelda", password: "hunter2" } }); + console.error("b", handler, { cb: handler }); + console.error("c %s %o", handler, handler); + const records = await flushLogs(); + expect(records).toHaveLength(2); + expect(JSON.stringify(records)).not.toMatch(/Zelda|hunter2/); + }); + + // WHY: JSON calls a function's own toJSON; the logger drops the function, so capture must too. + it("exports nothing from a nested function's own toJSON", async () => { + const fj = Object.assign(() => undefined, { + toJSON: () => ({ password: "hunter2", note: "Zelda" }), + }); + console.error("x", { cb: fj }, [fj]); + const [record] = await flushLogs(); + expect(JSON.stringify(record)).not.toMatch(/hunter2|Zelda/); + }); + + // WHY: the logger's JSON shows a Map or Set as {}, wherever a toJSON put it. + it("exports no Map, Set or byte view contents from inside a toJSON result", async () => { + const value = { + toJSON: () => ({ + nested: new Set([{ password: "hunter2" }]), + map: new Map([["k", "Zelda"]]), + bytes: new Uint8Array([104, 117, 110]), + }), + }; + console.error("d", value); + const [record] = await flushLogs(); + expect(JSON.stringify(record)).not.toMatch(/hunter2|Zelda|104, 117, 110/); + }); + + // WHY: the capture runs on the caller's path; a huge array must cost bounded time. + it("reads a bounded number of items from a huge array", async () => { + let reads = 0; + const huge = new Proxy(Array.from({ length: 200_000 }, (_, i) => i), { + get(target, key, receiver) { + if (typeof key === "string" && /^\d+$/.test(key)) reads++; + return Reflect.get(target, key, receiver); + }, + }); + written.error.mockImplementationOnce(() => undefined); + console.error("big", huge); + const [record] = await flushLogs(); + expect(reads).toBeLessThanOrEqual(2_000); + expect(record!.body).toMatch(/^big \[/); + }); + + // WHY: with no logScope set, the scope filter must still keep the app's own lines. + it("exports a console line and a log line under the default scope", async () => { + console.error("from console"); + log.info("from the logger"); + const records = await flushLogs(); + expect(bodies()).toEqual(["from console", "from the logger"]); + expect(records.map((r) => r.instrumentationScope.name)).toEqual(["acp-api", "acp-api"]); + }); + + it("exports no record from a scope other than the log scope", async () => { + logs.getLogger("@opentelemetry/instrumentation-runtime-node").emit({ body: "raw jane@x.com" }); + log.info("kept"); + expect(bodies()).toEqual([]); + await flushLogs(); + expect(bodies()).toEqual(["kept"]); + }); + + it("leaves console.log and console.info alone", async () => { + vi.spyOn(console, "info").mockImplementation(() => undefined); + console.info("not captured"); + await flushLogs(); + expect(bodies()).toEqual([]); + }); +}); diff --git a/src/__tests__/diag-console.test.ts b/src/__tests__/diag-console.test.ts new file mode 100644 index 0000000..ceb6c8d --- /dev/null +++ b/src/__tests__/diag-console.test.ts @@ -0,0 +1,49 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { diag } from "@opentelemetry/api"; +import { captureConsole, releaseConsole } from "../console-capture.js"; +import { shutdownTelemetry, startTelemetry } from "../sdk.js"; +import { exported, flushLogs, stopTelemetry } from "./otlp-export-helpers.js"; + +vi.mock("@opentelemetry/exporter-logs-otlp-proto", async () => + (await import("./otlp-export-helpers.js")).memoryLogExporterModule(), +); + +// Installed before @opentelemetry/api loads, so the SDK's console diag logger writes here. +const printed = vi.hoisted(() => { + const lines: string[] = []; + const keep = (...args: unknown[]) => void lines.push(args.map(String).join(" ")); + console.error = keep; + console.warn = keep; + return lines; +}); + +beforeAll(async () => { + // With OTEL_LOG_LEVEL set, NodeSDK installs its own console diag logger. + vi.stubEnv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://127.0.0.1:9"); + vi.stubEnv("OTEL_LOG_LEVEL", "ERROR"); + await startTelemetry({ + serviceName: "acp-api", + esmLoaderHook: false, + env: { OTEL_EXPORTER_OTLP_ENDPOINT: "http://127.0.0.1:9", OTEL_LOG_LEVEL: "ERROR" }, + }); + captureConsole(); + await flushLogs(); + exported.length = 0; +}); + +afterAll(async () => { + releaseConsole(); + await shutdownTelemetry(1_000); + await stopTelemetry(); +}); + +// WHY: an exporter failure reported through the console would otherwise be exported, fail, and +// report itself again, once per batch, forever. +describe("the SDK's console diag logger under captureConsole", () => { + it("prints an SDK error but never exports it", async () => { + diag.error("export failed: diag-sentinel"); + expect(printed.join("\n")).toContain("diag-sentinel"); + await flushLogs(); + expect(JSON.stringify(exported)).not.toContain("diag-sentinel"); + }); +}); diff --git a/src/__tests__/fatal.test.ts b/src/__tests__/fatal.test.ts new file mode 100644 index 0000000..8d666ce --- /dev/null +++ b/src/__tests__/fatal.test.ts @@ -0,0 +1,91 @@ +import { EventEmitter } from "node:events"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { installFatalHandlers } from "../fatal.js"; +import { log } from "../shared-log.js"; + +function setup( + flushTelemetry: (timeoutMs: number) => Promise, + report: (err: unknown) => void = () => undefined, +) { + const target = new EventEmitter(); + const order: string[] = []; + const exit = vi.fn((code: number) => void order.push(`exit ${code}`)); + installFatalHandlers({ + target: target as unknown as Pick, + stopIntake: () => void order.push("stop intake"), + report: (err) => { + order.push("report"); + report(err); + }, + flushTelemetry: (timeoutMs) => { + order.push(`flush telemetry ${timeoutMs}`); + return flushTelemetry(timeoutMs); + }, + exit, + }); + return { target, order, exit }; +} + +// A crashed worker must not pick up new jobs while it flushes, and a dead collector must not keep +// a crashed process alive past the flush budget. +describe("installFatalHandlers", () => { + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + process.exitCode = undefined; + }); + + it("logs, stops intake before any await, flushes, then exits 1", async () => { + const fatal = vi.spyOn(log, "fatal").mockImplementation(() => undefined); + const { target, order, exit } = setup(() => Promise.resolve()); + const err = new Error("boom"); + target.emit("uncaughtException", err); + expect(order).toEqual(["stop intake", "report"]); + expect(fatal).toHaveBeenCalledWith({ err }, "uncaught exception"); + await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1)); + expect(order).toEqual(["stop intake", "report", "flush telemetry 2000", "exit 1"]); + expect(process.exitCode).toBe(1); + }); + + it("exits after two seconds when the flush never finishes", async () => { + vi.useFakeTimers(); + vi.spyOn(log, "fatal").mockImplementation(() => undefined); + vi.spyOn(log, "flush").mockImplementation(() => undefined); + const { target, exit } = setup(() => new Promise(() => undefined)); + target.emit("unhandledRejection", new Error("lost")); + await vi.advanceTimersByTimeAsync(1_999); + expect(exit).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + expect(exit).toHaveBeenCalledWith(1); + }); + + it("logs a second fatal but stops and exits only once", async () => { + const fatal = vi.spyOn(log, "fatal").mockImplementation(() => undefined); + const { target, order, exit } = setup(() => Promise.resolve()); + target.emit("uncaughtException", new Error("one")); + target.emit("unhandledRejection", "two"); + await vi.waitFor(() => expect(exit).toHaveBeenCalled()); + expect(fatal).toHaveBeenCalledTimes(2); + expect(order.filter((o) => o === "stop intake")).toHaveLength(1); + expect(order.filter((o) => o === "report")).toHaveLength(1); + expect(exit).toHaveBeenCalledTimes(1); + }); + + // WHY: the error report goes to PostHog before the flush that sends it; a reporter that throws + // must not keep a crashed process alive. + it("reports the error once and still exits when the reporter throws", async () => { + vi.spyOn(log, "fatal").mockImplementation(() => undefined); + const reported: unknown[] = []; + const err = new Error("boom"); + const { target, exit } = setup( + () => Promise.resolve(), + (e) => { + reported.push(e); + throw new Error("posthog down"); + }, + ); + target.emit("uncaughtException", err); + await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1)); + expect(reported).toEqual([err]); + }); +}); diff --git a/src/__tests__/http-route.test.ts b/src/__tests__/http-route.test.ts new file mode 100644 index 0000000..795fde1 --- /dev/null +++ b/src/__tests__/http-route.test.ts @@ -0,0 +1,43 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { context, INVALID_SPAN_CONTEXT, ROOT_CONTEXT, trace } from "@opentelemetry/api"; +import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks"; +import { RPCType, setRPCMetadata, type RPCMetadata } from "@opentelemetry/core"; +import { setHttpRoute } from "../http-route.js"; +import { applyOptions, resetOptions } from "../options.js"; + +beforeAll(() => { + context.setGlobalContextManager(new AsyncLocalStorageContextManager().enable()); + applyOptions({ serviceName: "api", logScope: "backbone" }); +}); +afterAll(() => { + context.disable(); + resetOptions(); +}); + +describe("setHttpRoute", () => { + it("records the route on the HTTP request metadata", () => { + const rpc: RPCMetadata = { + type: RPCType.HTTP, + span: trace.wrapSpanContext(INVALID_SPAN_CONTEXT), + }; + context.with(setRPCMetadata(ROOT_CONTEXT, rpc), () => setHttpRoute("/v1/leases/:id", "GET")); + expect(rpc.route).toBe("/v1/leases/:id"); + }); + + it("renames the active span and tags the request id", () => { + const span = { + updateName: vi.fn(), + setAttribute: vi.fn(), + spanContext: () => INVALID_SPAN_CONTEXT, + }; + context.with(trace.setSpan(ROOT_CONTEXT, span as never), () => + setHttpRoute("/v1/leases/:id", "GET", "req-1"), + ); + expect(span.updateName).toHaveBeenCalledWith("GET /v1/leases/:id"); + expect(span.setAttribute).toHaveBeenCalledWith("backbone.request_id", "req-1"); + }); + + it("does nothing without an active span", () => { + expect(() => setHttpRoute("/x", "GET")).not.toThrow(); + }); +}); diff --git a/src/__tests__/log-errors.test.ts b/src/__tests__/log-errors.test.ts new file mode 100644 index 0000000..bc7949e --- /dev/null +++ b/src/__tests__/log-errors.test.ts @@ -0,0 +1,266 @@ +import { describe, expect, it } from "vitest"; +import { createTestLogger } from "../testing.js"; + +const named = (name: string, message: string, extra: object = {}) => + Object.assign(new Error(message), { name }, extra); + +function logged(fields: Record): { line: string; err: Record } { + const { logger, records } = createTestLogger(); + logger.error(fields, "failed"); + return { line: JSON.stringify(records[0]), err: records[0]!.err as Record }; +} + +// An error's own properties are whatever a library attached: request bodies, commands, row values. +// Only the listed fields may leave, so a new library cannot leak by adding a property. +describe("deny-by-default error serialization", () => { + it("drops an ioredis ReplyError's command args", () => { + const err = named("ReplyError", "WRONGTYPE Operation against a key", { + command: { name: "set", args: ["bull:email:1", '{"to":"jane@example.com","jobId":"j-1"}'] }, + }); + const { line, err: out } = logged({ err }); + expect(line).not.toMatch(/jane@example\.com|bull:email:1/); + expect(out).toMatchObject({ type: "Error", name: "ReplyError", message: err.message }); + }); + + it("drops the input of a Node ERR_INVALID_URL", () => { + let err: unknown; + try { + new URL("https://jane:hunter2@exa mple.com/x"); + } catch (caught) { + err = caught; + } + const { line, err: out } = logged({ err }); + expect(line).not.toMatch(/hunter2|jane/); + expect(out).toMatchObject({ + type: "TypeError", + code: "ERR_INVALID_URL", + message: "Invalid URL", + }); + }); + + it("drops a pg DatabaseError's detail", () => { + const err = named("error", 'new row violates check constraint "chk_token"', { + code: "23514", + detail: "Failing row contains (t-1, Bearer SENTINEL-TOKEN-9, 2026-01-01).", + table: "api_tokens", + }); + const { line, err: out } = logged({ err }); + expect(line).not.toContain("SENTINEL-TOKEN-9"); + expect(out).toMatchObject({ code: "23514", message: err.message }); + expect(out).not.toHaveProperty("detail"); + }); + + it("keeps only type, name, message, stack and codes on an unknown error", () => { + const err = named("CustomError", "bad", { + code: "E1", + errorCode: "X", + status: 500, + statusCode: 502, + body: { first_name: "Jane" }, + response: { data: "r" }, + config: { headers: { Authorization: "Bearer SENTINEL-3" } }, + raw: "raw", + headers: { a: "b" }, + input: "i", + }); + const { err: out } = logged({ err }); + expect(Object.keys(out).sort()).toEqual( + ["code", "errorCode", "message", "name", "stack", "status", "statusCode", "type"].sort(), + ); + }); + + it("sanitizes every link of the cause chain the same way", () => { + const inner = named("ReplyError", "inner", { command: { args: ["SENTINEL-C"] }, code: "E2" }); + const err = new Error("outer", { cause: inner }); + const { line, err: out } = logged({ err }); + expect(line).not.toContain("SENTINEL-C"); + expect(out.cause).toMatchObject({ name: "ReplyError", message: "inner", code: "E2" }); + }); + + it("serializes AggregateError members", () => { + const err = new AggregateError( + [named("PrismaClientKnownRequestError", "x=jane@example.com", { code: "P2002" })], + "all failed", + ); + const { line, err: out } = logged({ err }); + expect(line).not.toContain("jane@example.com"); + expect(out.errors).toEqual([ + expect.objectContaining({ message: "PrismaClientKnownRequestError P2002" }), + ]); + }); +}); + +describe("error families", () => { + it("keeps value-free code for Prisma errors, including init errors' errorCode", () => { + const known = named("PrismaClientKnownRequestError", "Unique failed on jane@example.com", { + code: "P2002", + meta: { target: ["email"], cause: "jane@example.com" }, + }); + const init = named("PrismaClientInitializationError", "Can't reach jane@db.example.com", { + errorCode: "P1001", + }); + const a = logged({ err: known }); + const b = logged({ err: init }); + expect(a.line + b.line).not.toContain("jane@"); + expect(a.err).toMatchObject({ + message: "PrismaClientKnownRequestError P2002", + meta: { target: ["email"] }, + }); + expect(b.err).toMatchObject({ message: "PrismaClientInitializationError P1001" }); + }); + + it("keeps value-free code for DriverAdapterError and drops its cause", () => { + const err = named("DriverAdapterError", "dup key (ssn)=(123-45-6789)", { + cause: named("Error", "value 123-45-6789", { originalCode: "23505" }), + }); + const { line, err: out } = logged({ err }); + expect(line).not.toContain("123-45-6789"); + expect(out).toMatchObject({ message: "DriverAdapterError 23505" }); + expect(out).not.toHaveProperty("cause"); + }); + + it("drops ZodError issues and values", () => { + const err = named("ZodError", "received bob@secret.test", { + issues: [{ code: "invalid_string", received: "bob@secret.test" }], + }); + const { line, err: out } = logged({ err }); + expect(line).not.toContain("bob@secret.test"); + expect(out).toMatchObject({ message: "ZodError 1 issue(s)" }); + expect(out).not.toHaveProperty("issues"); + }); + + it("keeps only allowlisted fields of a Stripe error", () => { + class StripeCardError extends Error {} + const err = Object.assign(new StripeCardError("Your card was declined."), { + name: "StripeCardError", + type: "StripeCardError", + code: "card_declined", + decline_code: "insufficient_funds", + statusCode: 402, + requestId: "req_1", + raw: { payment_method: { billing_details: { name: "Jane Q. Resident" } } }, + headers: { "idempotency-key": "k" }, + }); + const { line, err: out } = logged({ err }); + expect(line).not.toMatch(/Jane Q\. Resident|idempotency-key/); + expect(out).toMatchObject({ + type: "StripeCardError", + code: "card_declined", + decline_code: "insufficient_funds", + statusCode: 402, + requestId: "req_1", + message: "Your card was declined.", + }); + }); + + it("drops the response body of a HappyCo error but keeps status and outcome", () => { + const err = named("HappyCoError", "HappyCo answered 500", { + status: 500, + outcome: "UNKNOWN", + body: { errors: [{ message: "tenant Jane at 42 Privet Drive" }] }, + }); + const { line, err: out } = logged({ err }); + expect(line).not.toMatch(/Jane|Privet/); + expect(out).toMatchObject({ status: 500, outcome: "UNKNOWN", message: "HappyCo answered 500" }); + }); +}); + +describe("error text scrubbing", () => { + it("scrubs SSN, email and long digit runs from a message and its stack header", () => { + const err = new Error("lookup failed for alice@example.com 123-45-6789 acct 000123456789"); + const { err: out } = logged({ err }); + expect(out.message).toBe("lookup failed for [redacted] [redacted] acct [redacted]"); + expect(out.stack).not.toMatch(/alice@example\.com|123-45-6789|000123456789/); + }); + + // Provider ids are prefixed or UUIDs, so the digit scrub must leave them readable. + it("leaves UUIDs and prefixed provider ids alone", () => { + const text = "pi_12345678901 for 123e4567-e89b-12d3-a456-426614174000 req_00000000"; + const { err: out } = logged({ err: new Error(text) }); + expect(out.message).toBe(text); + }); + + it("keeps stack frames intact, including a pnpm store path", () => { + const err = new Error("boom for jane@example.com"); + const frame = + " at run (/app/node_modules/.pnpm/@hono+node-server@1.19.5_hono@4.9.0/node_modules/x.js:1:2)"; + err.stack = `Error: boom for jane@example.com\n${frame}`; + const { err: out } = logged({ err }); + expect(out.stack).toBe(`Error: boom for [redacted]\n${frame}`); + }); + + it("keeps the original stack frames when a chain is copied", () => { + function thrownHere() { + return new Error("outer", { cause: new Error("inner") }); + } + const { err: out } = logged({ err: thrownHere() }); + expect(String(out.stack)).toContain("thrownHere"); + }); + + it("keeps the message of a plain Error", () => { + expect(logged({ err: new Error("queue is down") }).err).toMatchObject({ + message: "queue is down", + }); + }); +}); + +describe("error serializer robustness", () => { + it("does not throw on a frozen Prisma error", () => { + const err = Object.freeze( + named("PrismaClientKnownRequestError", "x=jane@example.com", { code: "P2002" }), + ); + expect(logged({ err }).line).not.toContain("jane@example.com"); + }); + + it("does not throw on a throwing cause getter", () => { + const err = new Error("boom"); + Object.defineProperty(err, "cause", { + get() { + throw new Error("nope"); + }, + }); + expect(logged({ err }).err).toMatchObject({ message: "boom" }); + }); + + it("serializes errors under `error` and `e` keys", () => { + const mk = () => + named("PrismaClientKnownRequestError", "x=jane@example.com", { code: "P2002" }); + const { logger, records } = createTestLogger(); + logger.error({ error: mk(), e: mk() }, "f"); + expect(JSON.stringify(records[0])).not.toContain("jane@example.com"); + expect(records[0]).toMatchObject({ error: { message: "PrismaClientKnownRequestError P2002" } }); + }); + + it("drops causes beyond the depth cap", () => { + let err: Error = named("PrismaClientKnownRequestError", "x=jane@example.com", { + code: "P2002", + }); + for (let i = 0; i < 15; i++) err = new Error(`level ${i}`, { cause: err }); + expect(logged({ err }).line).not.toContain("jane@example.com"); + }); + + it("scrubs the name and type of a non-Error err object", () => { + const { err, line } = logged({ err: { name: "jane@example.com", message: "m" } }); + expect(line).not.toContain("jane@example.com"); + expect(err).toMatchObject({ type: "[redacted]", name: "[redacted]" }); + }); + + it("marks a cause cycle and a self-referencing errors array as circular", () => { + const cyclic = new Error("loop"); + cyclic.cause = cyclic; + expect(logged({ err: cyclic }).err).toMatchObject({ message: "loop", cause: "[circular]" }); + + const aggregate = new AggregateError([], "many"); + (aggregate as { errors: unknown[] }).errors = [aggregate]; + expect(logged({ err: aggregate }).err).toMatchObject({ errors: ["[circular]"] }); + }); + + it("serializes a shared, non-cyclic error everywhere it appears", () => { + const shared = new Error("shared"); + const { err } = logged({ err: new AggregateError([shared, shared], "two") }); + expect(err.errors).toEqual([ + expect.objectContaining({ message: "shared" }), + expect.objectContaining({ message: "shared" }), + ]); + }); +}); diff --git a/src/__tests__/log-format-args.test.ts b/src/__tests__/log-format-args.test.ts new file mode 100644 index 0000000..bcc23a3 --- /dev/null +++ b/src/__tests__/log-format-args.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import { createTestLogger } from "../testing.js"; + +// A rethrown pg error: its `detail` and message carry the row values that failed. +function pgError(): Error { + return Object.assign(new Error("duplicate key for jane@example.com"), { + code: "23505", + detail: "Key (email)=(jane@example.com) already exists PG-DETAIL", + table: "Tenant", + }); +} + +// Each value hides PII or provider text somewhere a format arg can carry it. +const CASES: [string, () => Record | unknown[]][] = [ + ["a string under err", () => ({ tenantId: "t-1", err: "jane@example.com raw" })], + [ + "an array of error-shaped objects under err", + () => ({ tenantId: "t-1", err: [{ detail: "ARR-DETAIL", message: "a" }] }), + ], + ["an error under cause", () => ({ tenantId: "t-1", cause: pgError() })], + ["an array holding an error", () => [{ tenantId: "t-1" }, pgError()]], + ["an email under a key that is not PII", () => ({ tenantId: "t-1", user: "jane@example.com" })], + ["a deep ssn key", () => ({ tenantId: "t-1", nested: { deep: { ssn: "123-45-6789" } } })], + [ + "an email in a URL path", + () => ({ tenantId: "t-1", url: new URL("https://x.io/residents/jane@example.com/statements") }), + ], + ["a mailto URL", () => ({ tenantId: "t-1", url: new URL("mailto:jane@example.com") })], +]; +const LEAK = /jane@example\.com|123-45-6789|PG-DETAIL|ARR-DETAIL|"detail"/; + +// Format args and the log object share one sanitizer, so the same value is safe in either place. +describe("format args are sanitized like the log object", () => { + it.each(["%j", "%o"])("leaks nothing through a %s format arg", (fmt) => { + for (const [, build] of CASES) { + const { logger, records } = createTestLogger(); + const info = logger.info.bind(logger) as (msg: string, ...args: unknown[]) => void; + info(`ctx ${fmt}`, build()); + const msg = String(records[0]!.msg); + expect(msg).not.toMatch(LEAK); + expect(msg).toContain("t-1"); + } + }); + + it.each(CASES.filter(([, build]) => !Array.isArray(build())))( + "leaks nothing through the log object: %s", + (_, build) => { + const { logger, records } = createTestLogger(); + logger.info(build() as Record, "ctx"); + expect(JSON.stringify(records[0])).not.toMatch(LEAK); + expect(records[0]).toMatchObject({ tenantId: "t-1", msg: "ctx" }); + }, + ); + + it("passes a primitive format arg through the same string scrub", () => { + const { logger, records } = createTestLogger(); + logger.info("ctx %s %d", "jane@example.com", 42); + expect(records[0]!.msg).toBe("ctx [redacted] 42"); + }); + + it("keeps an error's code and scrubbed message in a format arg", () => { + const { logger, records } = createTestLogger(); + logger.info("ctx %j", { err: pgError() }); + expect(String(records[0]!.msg)).toContain('"code":"23505"'); + expect(String(records[0]!.msg)).toContain('"message":"duplicate key for [redacted]"'); + }); + + // Format args are the sanitized, inert values: no Date or function hook of the caller's runs + // after the sanitizer, so these renderings are intended. + it.each([ + ["date %s", new Date(0), "date 1970-01-01T00:00:00.000Z"], + ["date %d", new Date(0), "date NaN"], + ["re %s", /x/, "re [object Object]"], + ["map %s", new Map([["a", 1]]), "map [object Object]"], + ["fn %s", function named() {}, "fn undefined"], + ["fn %o", function named() {}, "fn %o"], + ])("renders %s as the sanitized value", (format, value, expected) => { + const { logger, records } = createTestLogger(); + (logger.info as (...args: unknown[]) => void).call(logger, format, value); + expect(records[0]!.msg).toBe(expected); + }); +}); diff --git a/src/__tests__/log-money-paths.test.ts b/src/__tests__/log-money-paths.test.ts new file mode 100644 index 0000000..713c77f --- /dev/null +++ b/src/__tests__/log-money-paths.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { createTestLogger } from "../testing.js"; + +// The payment paths log record ids so a failure can be traced to its row, and they log provider +// errors whose payloads hold bank and card data. The ids must survive; the payloads must not. +describe("money-path log lines", () => { + it("keeps record ids and drops account numbers, PostGrid detail and Stripe raw", () => { + const { logger, records } = createTestLogger(); + const postgrid = Object.assign(new Error("Invalid bank account 000123456789"), { + name: "PostgridError", + status: 400, + body: { error: { message: "account 000123456789 routing 021000021 for Jane Resident" } }, + }); + const stripe = Object.assign(new Error("Your bank account was declined."), { + name: "StripeInvalidRequestError", + type: "StripeInvalidRequestError", + code: "bank_account_declined", + raw: { bank_account: { account_holder_name: "Jane Resident", last4: "6789" } }, + }); + logger.error( + { + event: "postgrid_registration_failed", + bankAccountId: "ba-1", + emailId: "em-1", + resendEmailId: "re-1", + paymentId: "pay-1", + accountNumber: "000123456789", + routingNumber: "021000021", + bankAccount: { accountNumber: "000123456789" }, + err: postgrid, + cause: stripe, + }, + "registration failed", + ); + const line = JSON.stringify(records[0]); + expect(line).not.toMatch(/000123456789|021000021|Jane Resident|6789/); + expect(records[0]).toMatchObject({ + bankAccountId: "ba-1", + emailId: "em-1", + resendEmailId: "re-1", + paymentId: "pay-1", + err: { type: "Error", name: "PostgridError", status: 400 }, + cause: { name: "StripeInvalidRequestError", code: "bank_account_declined" }, + }); + }); +}); diff --git a/src/__tests__/log-otlp-helpers.ts b/src/__tests__/log-otlp-helpers.ts new file mode 100644 index 0000000..aa773fc --- /dev/null +++ b/src/__tests__/log-otlp-helpers.ts @@ -0,0 +1,42 @@ +import { Writable } from "node:stream"; +import { + InMemoryLogRecordExporter, + LoggerProvider, + SimpleLogRecordProcessor, + type ReadableLogRecord, +} from "@opentelemetry/sdk-logs"; +import { setLogLineSink } from "../log-sink.js"; +import { logLineSink, ScopedLogRecordProcessor } from "../log-otlp.js"; + +/** Backbone's log scope, passed where the app supplies one. */ +export const LOG_SCOPE = "backbone"; + +export function capture(): { lines: string[]; stream: Writable } { + const lines: string[] = []; + const stream = new Writable({ + write(chunk: Buffer, _encoding, callback) { + lines.push(chunk.toString()); + callback(); + }, + }); + return { lines, stream }; +} + +/** Installs the real sink over an in-memory exporter behind the production scope filter. */ +export function exporting() { + const exporter = new InMemoryLogRecordExporter(); + const provider = new LoggerProvider({ + processors: [ + new ScopedLogRecordProcessor(new SimpleLogRecordProcessor({ exporter }), LOG_SCOPE), + ], + }); + setLogLineSink(logLineSink(provider.getLogger(LOG_SCOPE))); + return { + provider, + /** Records exported so far, once the sink's queued emissions have run. */ + records: async (): Promise => { + await Promise.resolve(); + return exporter.getFinishedLogRecords(); + }, + }; +} diff --git a/src/__tests__/log-otlp-safety.test.ts b/src/__tests__/log-otlp-safety.test.ts new file mode 100644 index 0000000..a8bf14b --- /dev/null +++ b/src/__tests__/log-otlp-safety.test.ts @@ -0,0 +1,120 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + LoggerProvider, + type LogRecordProcessor, + type SdkLogRecord, +} from "@opentelemetry/sdk-logs"; +import { logLineSink, ScopedLogRecordProcessor } from "../log-otlp.js"; +import { createLogger } from "../logger.js"; +import { forwardLogLine, setLogLineSink } from "../log-sink.js"; +import { buildInstrumentations } from "../instrumentations.js"; +import { capture, exporting, LOG_SCOPE } from "./log-otlp-helpers.js"; + +afterEach(() => setLogLineSink(undefined)); + +describe("OTLP log export safety", () => { + // WHY: an exporter failure is logged as an otel_diag line; exporting that line would feed the + // failing exporter its own failures, once per batch, forever. + it("does not export OTel's own diagnostics", async () => { + const { records } = exporting(); + const logger = createLogger(() => "api", capture().stream); + logger.child({ component: "telemetry" }).error({ event: "otel_diag" }, "export failed"); + expect(await records()).toHaveLength(0); + }); + + // WHY: the boot and health lines tell an operator from Loki whether telemetry is running. + it("exports the telemetry package's other lines", async () => { + const { records } = exporting(); + const logger = createLogger(() => "api", capture().stream).child({ component: "telemetry" }); + logger.info({ event: "telemetry_started" }, "telemetry_started"); + logger.warn({ event: "telemetry_start_failed" }, "telemetry_start_failed"); + expect((await records()).map((r) => r.attributes.event)).toEqual([ + "telemetry_started", + "telemetry_start_failed", + ]); + }); + + // WHY: an instrumentation that emits its own records (a raw exception message and stack) must + // never reach the exporter around the scrubbing logger. + it("exports no record from a logger scope other than backbone", async () => { + const { provider, records } = exporting(); + provider.getLogger("@opentelemetry/instrumentation-runtime-node").emit({ + body: "uncaught jane@example.com", + attributes: { "exception.message": "jane@example.com" }, + }); + expect(await records()).toHaveLength(0); + }); + + it("keeps the runtime instrumentation's uncaught-exception capture off", () => { + const runtime = buildInstrumentations([]).find( + (i) => i.instrumentationName === "@opentelemetry/instrumentation-runtime-node", + ); + expect(runtime?.getConfig()).toMatchObject({ captureUncaughtException: false }); + }); + + // WHY: a sink that logs (an SDK diagnostic raised while exporting) must not recurse into itself. + it("does not re-enter the sink for a line logged while the sink runs", () => { + const seen: string[] = []; + const out = capture(); + const logger = createLogger(() => "api", out.stream); + setLogLineSink((line) => { + seen.push(line); + logger.warn("logged while exporting"); + }); + logger.info("outer"); + expect(seen).toHaveLength(1); + expect(seen[0]).toContain('"msg":"outer"'); + expect(out.lines).toHaveLength(2); + }); + + it("never throws into the caller when the sink throws, and still writes the line", () => { + setLogLineSink(() => { + throw new Error("exporter down"); + }); + const out = capture(); + const logger = createLogger(() => "api", out.stream); + expect(() => logger.info("still logged")).not.toThrow(); + expect(out.lines).toHaveLength(1); + }); + + it("returns the line unchanged whether or not a sink is installed", () => { + expect(forwardLogLine("x\n")).toBe("x\n"); + setLogLineSink(() => undefined); + expect(forwardLogLine("x\n")).toBe("x\n"); + }); + + // WHY: an SDK diagnostic raised while emitting a record must follow the line it is about. + it("emits the record after the line is written, never inside the log call", async () => { + const out = capture(); + const logger = createLogger(() => "api", out.stream); + let reported = false; + const noting: LogRecordProcessor = { + onEmit: () => { + if (reported) return; + reported = true; + logger.warn({ event: "sdk_note" }, "about the record"); + }, + forceFlush: () => Promise.resolve(), + shutdown: () => Promise.resolve(), + }; + const provider = new LoggerProvider({ processors: [noting] }); + setLogLineSink(logLineSink(provider.getLogger(LOG_SCOPE))); + logger.info("the line"); + expect(reported).toBe(false); + await Promise.resolve(); + expect(out.lines.map((l) => (JSON.parse(l) as { msg: string }).msg)).toEqual([ + "the line", + "about the record", + ]); + }); + + it("drops another scope's record in onEmit even when enabled() is bypassed", () => { + const inner = { onEmit: vi.fn(), forceFlush: vi.fn(), shutdown: vi.fn() }; + const scoped = new ScopedLogRecordProcessor(inner as unknown as LogRecordProcessor, LOG_SCOPE); + const record = (name: string) => ({ instrumentationScope: { name } }) as SdkLogRecord; + scoped.onEmit(record("@opentelemetry/instrumentation-runtime-node")); + expect(inner.onEmit).not.toHaveBeenCalled(); + scoped.onEmit(record("backbone")); + expect(inner.onEmit).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/__tests__/log-otlp.test.ts b/src/__tests__/log-otlp.test.ts new file mode 100644 index 0000000..0308491 --- /dev/null +++ b/src/__tests__/log-otlp.test.ts @@ -0,0 +1,162 @@ +import { afterEach, beforeAll, describe, expect, it } from "vitest"; +import { context, ROOT_CONTEXT, trace, TraceFlags } from "@opentelemetry/api"; +import { SeverityNumber } from "@opentelemetry/api-logs"; +import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks"; +import { createLogger } from "../logger.js"; +import { hasLogLineSink, setLogLineSink } from "../log-sink.js"; +import { logLineSink, NO_MESSAGE } from "../log-otlp.js"; +import { capture, exporting, LOG_SCOPE } from "./log-otlp-helpers.js"; + +const PII = /jane@example\.com|123-45-6789|SENTINEL-PII|Jane|000123456789/; + +beforeAll(() => { + context.setGlobalContextManager(new AsyncLocalStorageContextManager().enable()); +}); + +afterEach(() => setLogLineSink(undefined)); + +describe("OTLP log export", () => { + // WHY: the exporter must see only what the scrubbing logger writes, never the raw call. + it("exports a record with no PII from the message, nested fields, error or query", async () => { + const { records } = exporting(); + const logger = createLogger(() => "api", capture().stream); + logger.error( + { + tenant: { firstName: "Jane", contact: { email: "jane@example.com" } }, + account: { accountNumber: "000123456789" }, + err: new Error("charge failed for jane@example.com ssn 123-45-6789"), + url: new URL("https://api.test/v1/tenants?search=SENTINEL-PII"), + path: "GET /v1/tenants?search=SENTINEL-PII", + }, + "lookup for jane@example.com 123-45-6789 at /v1/tenants?search=SENTINEL-PII", + ); + const [record] = await records(); + expect(record).toBeDefined(); + expect(JSON.stringify({ body: record!.body, attributes: record!.attributes })).not.toMatch(PII); + }); + + it("exports exactly the fields of the line the logger writes", async () => { + const { records } = exporting(); + const out = capture(); + const logger = createLogger(() => "worker", out.stream); + logger.child({ component: "jobs" }).warn({ jobId: "j-1", nested: { n: 1 } }, "job slow"); + const { + msg, + level, + time: _time, + ...rest + } = JSON.parse(out.lines[0]!) as Record; + const [record] = await records(); + expect(record!.body).toBe(msg); + expect(record!.severityText).toBe(level); + expect(record!.attributes).toEqual(rest); + }); + + it.each([ + ["trace", SeverityNumber.TRACE], + ["debug", SeverityNumber.DEBUG], + ["info", SeverityNumber.INFO], + ["warn", SeverityNumber.WARN], + ["error", SeverityNumber.ERROR], + ["fatal", SeverityNumber.FATAL], + ] as const)("maps pino level %s to its OTel severity", async (level, severity) => { + const { records } = exporting(); + const logger = createLogger(() => "api", capture().stream); + logger.level = "trace"; + logger[level]("x"); + const [record] = await records(); + expect(record!.severityNumber).toBe(severity); + expect(record!.severityText).toBe(level); + }); + + it("gives a level it does not know an unspecified severity, keeping its name", async () => { + const { records, provider } = exporting(); + logLineSink(provider.getLogger(LOG_SCOPE))('{"level":"notice","msg":"x"}'); + const [record] = await records(); + expect(record!.severityNumber).toBe(SeverityNumber.UNSPECIFIED); + expect(record!.severityText).toBe("notice"); + }); + + // WHY: a record must be placed at the moment the line was logged, not when it was exported. + it("takes the record time from the line's time field", async () => { + const { records, provider } = exporting(); + logLineSink(provider.getLogger(LOG_SCOPE))( + '{"level":"info","time":"2024-01-02T03:04:05.678Z","msg":"past"}', + ); + const [record] = await records(); + expect(record!.hrTime).toEqual([ + Math.floor(Date.parse("2024-01-02T03:04:05.678Z") / 1000), + 678e6, + ]); + }); + + it("uses event as the body of a line with no message, else a fixed marker", async () => { + const { records } = exporting(); + const logger = createLogger(() => "api", capture().stream); + logger.info({ event: "job_done" }); + logger.info({ jobId: "j-1" }); + const [withEvent, bare] = await records(); + expect(withEvent!.body).toBe("job_done"); + expect(bare!.body).toBe(NO_MESSAGE); + }); + + it("carries the active span's trace and span ids as the record context", async () => { + const { records } = exporting(); + const logger = createLogger(() => "api", capture().stream); + const spanContext = { + traceId: "0af7651916cd43dd8448eb211c80319c", + spanId: "b7ad6b7169203331", + traceFlags: TraceFlags.SAMPLED, + }; + context.with(trace.setSpanContext(ROOT_CONTEXT, spanContext), () => logger.info("inside")); + const [record] = await records(); + expect(record!.spanContext).toMatchObject(spanContext); + expect(record!.attributes).not.toHaveProperty("trace_id"); + expect(record!.attributes).not.toHaveProperty("span_id"); + }); + + // WHY: a caller's own `trace_id` (a Stripe payout's) must not become the record's trace. + it("keeps a caller's trace_id as a renamed attribute, never as the record context", async () => { + const { records } = exporting(); + const logger = createLogger(() => "api", capture().stream); + logger.info({ trace_id: "0af7651916cd43dd8448eb211c80319c", span_id: "b7ad6b7169203331" }); + const [record] = await records(); + expect(record!.spanContext).toBeUndefined(); + expect(record!.attributes).toMatchObject({ + "field.trace_id": "0af7651916cd43dd8448eb211c80319c", + "field.span_id": "b7ad6b7169203331", + }); + }); + + // WHY: the SDK keeps a record's first 128 attributes, and the logger writes `app` last. + it("keeps app, env, component and event on a 200-field line", async () => { + const { records } = exporting(); + const logger = createLogger(() => "api", capture().stream).child({ component: "jobs" }); + const wide: Record = {}; + for (let i = 0; i < 200; i++) wide[`f${i}`] = i; + logger.info({ ...wide, event: "wide_line" }, "wide"); + const [record] = await records(); + expect(Object.keys(record!.attributes)).toHaveLength(128); + expect(record!.attributes).toMatchObject({ + app: "api", + env: expect.any(String), + component: "jobs", + event: "wide_line", + }); + }); + + it("writes byte-for-byte the same stdout line with and without export", () => { + const app = () => "api"; + const plain = capture(); + createLogger(app, plain.stream).info({ a: 1 }, "same"); + exporting(); + const teed = capture(); + createLogger(app, teed.stream).info({ a: 1 }, "same"); + const strip = (line: string) => line.replace(/"time":"[^"]+"/, ""); + expect(strip(teed.lines[0]!)).toBe(strip(plain.lines[0]!)); + }); + + it("installs no sink until the SDK boots one", () => { + expect(hasLogLineSink()).toBe(false); + }); +}); diff --git a/src/__tests__/log-redact.test.ts b/src/__tests__/log-redact.test.ts new file mode 100644 index 0000000..313d26c --- /dev/null +++ b/src/__tests__/log-redact.test.ts @@ -0,0 +1,229 @@ +import { describe, expect, it } from "vitest"; +import { createTestLogger } from "../testing.js"; +import { isSensitiveKey } from "../log-redact.js"; + +const VALUE = "SENTINEL-VALUE-42"; + +// Redaction is by key name, so the key table is the policy: these keys hold PII or credentials. +const REDACTED_KEYS = [ + ...[ + "ssnEnc", + "dobEnc", + "coApplicantSsnEnc", + "coApplicantDobEnc", + "ssn", + "social_security_number", + ], + ...["accountNumberEnc", "routingNumberEnc", "routing_number", "accountNumber", "bankAccount"], + ...[ + "taxIdEnc", + "taxId", + "tax_id", + "taxpayerId", + "ein", + "employerEin", + "employerEIN", + "ein_number", + ], + ...["signingSecretEnc", "claimToken", "postgridRegistrationToken", "apiKey", "x-api-key"], + ...["applicantEmail", "contactEmail", "callerEmail", "submittedByVendorEmail", "email"], + ...["contactPhone", "callerPhone", "accountHolderName", "date_of_birth", "dateOfBirth"], + ...["destinationAccountNumberEncSnapshot", "sourceAccountNumberEncSnapshot", "birthday"], + ...["set-cookie", "authorization", "x-clerk-auth", "stripe-signature", "svix-signature"], + ...["protectedPayloadEnc", "iban", "passport", "driversLicense", "abaNumber"], + // person names + ...["firstName", "lastName", "middleName", "fullName", "legalName", "preferredName"], + ...["first_name", "last_name", "middle_name", "full_name", "legal_name", "preferred_name"], + ...["contactName", "callerName", "occupantName", "payeeName", "billToName", "surname"], + // addresses + ...["streetAddress", "mailingAddress", "homeAddress", "street_address", "mailing_address"], + ...["address", "address1", "addressLine1", "address_line_1", "postalCode", "postal_code"], + ...["zip", "zipCode", "zip_code"], + ...["forwardingAddress", "forwarding_address", "propertyAddress", "ipAddress", "street"], + ...["street1", "street2", "line1", "line2", "Line1", "mobile", "mobilePhone"], + ...["nameOnAccount", "name_on_account"], +]; + +// These must stay readable: record ids, and names that are not a person's. +const KEPT_KEYS = [ + ...["tenantId", "leaseId", "applicationId", "emailId", "bankAccountId", "resendEmailId"], + ...["phoneIds", "token_id", "accountNumberId", "trace_id", "span_id"], + ...["name", "queueName", "jobName", "categoryName", "targetName", "propertyName", "fileName"], + ...["migrationName", "fromName", "vendorName", "businessName", "className", "bankName"], + ...["amount", "status", "route", "unitNumber", "totalAmount", "balance", "currency"], + ...["description", "protein", "reference", "audience", "ipAddressCount", "addressee"], + ...["forwardingAddressId", "streetId", "line1Id", "lineItems", "line3", "streetsCount"], + ...["addressCount", "lineNumber", "mobileId", "accountName", "nameOnAccountId"], +]; + +describe("key-pattern redaction", () => { + it.each(REDACTED_KEYS)("redacts %s at depth 1 and depth 4", (key) => { + expect(isSensitiveKey(key)).toBe(true); + const { logger, records } = createTestLogger(); + logger.info({ [key]: VALUE, a: { b: { c: { [key]: VALUE } } } }, "m"); + expect(JSON.stringify(records[0])).not.toContain(VALUE); + }); + + it.each(KEPT_KEYS)("keeps %s", (key) => { + expect(isSensitiveKey(key)).toBe(false); + const { logger, records } = createTestLogger(); + logger.info({ nested: { [key]: VALUE } }, "m"); + expect(records[0]).toMatchObject({ nested: { [key]: VALUE } }); + }); + + it("redacts inside arrays of objects", () => { + const { logger, records } = createTestLogger(); + logger.info({ rows: [{ ssn: VALUE }, { nested: [{ contactEmail: VALUE }] }] }, "m"); + expect(JSON.stringify(records[0])).not.toContain(VALUE); + }); + + it("handles cycles and leaves the caller's object untouched", () => { + const { logger, records } = createTestLogger(); + const input: Record = { ssn: VALUE, tenantId: "t-1" }; + input.self = input; + logger.info(input, "m"); + expect(JSON.stringify(records[0])).not.toContain(VALUE); + expect(input.ssn).toBe(VALUE); + expect(input.self).toBe(input); + }); + + it("fails closed when a getter throws", () => { + const { logger, records } = createTestLogger(); + const bad = { + get boom(): string { + throw new Error("no"); + }, + }; + expect(() => logger.info({ bad }, "m")).not.toThrow(); + expect(records[0]).toMatchObject({ log_redaction_failed: true }); + }); +}); + +describe("walker bounds", () => { + it("bounds breadth", () => { + const { logger, records } = createTestLogger(); + const wide = Object.fromEntries(Array.from({ length: 5000 }, (_, i) => [`k${i}`, i])); + logger.info({ items: Array.from({ length: 5000 }, (_, i) => i), wide }, "m"); + const line = JSON.stringify(records[0]); + expect(line.length).toBeLessThan(20_000); + expect(line).toContain("[truncated]"); + }); + + // Depth and breadth caps alone still allow 1,000^8 nodes; the per-line budget stops that. + it("caps the total number of values walked per line", () => { + const { logger, records } = createTestLogger(); + const row = Object.fromEntries(Array.from({ length: 900 }, (_, i) => [`c${i}`, { v: i }])); + logger.info({ rows: Array.from({ length: 900 }, () => row) }, "m"); + const line = JSON.stringify(records[0]); + expect(line).toContain("[truncated]"); + expect(line.length).toBeLessThan(200_000); + }); + + // Strings count toward the budget too: wide arrays of short strings must not scrub a million. + it("counts every value, strings included, and checks the budget per array item", () => { + const { logger, records } = createTestLogger(); + const rows = Array.from({ length: 1000 }, () => Array.from({ length: 1000 }, () => "s")); + const started = performance.now(); + logger.info({ rows }, "m"); + const elapsed = performance.now() - started; + expect(JSON.stringify(records[0])).toContain("[truncated]"); + expect(elapsed).toBeLessThan(200); + }); +}); + +describe("object keys", () => { + // A key is logged like a value: an email or SSN used as a map key must not leak. + it("redacts a key with an email or SSN shape and keeps colliding keys apart", () => { + const { logger, records } = createTestLogger(); + logger.info({ counts: { ["jane@example.com"]: 2, ["123-45-6789"]: 3, ok: 1 } }, "m"); + expect(JSON.stringify(records[0])).not.toMatch(/jane@example\.com|123-45-6789/); + const counts = records[0]!.counts as Record; + expect(Object.values(counts).sort()).toEqual([1, 2, 3]); + expect(counts).toMatchObject({ "[redacted]": 2, ok: 1 }); + }); + + // A caller key that looks like a marker must not overwrite, or be overwritten by, a real value. + it("keeps every value when a literal marker key is also present", () => { + const { logger, records } = createTestLogger(); + const counts = { "jane@example.com": 2, "bob@example.com": 3, "[redacted]": 9 }; + logger.info({ counts, more: { "[redacted]#1": 7, "a@b.co": 8, "[truncated]": 6 } }, "m"); + const out = records[0]!.counts as Record; + expect(Object.values(out).sort()).toEqual([2, 3, 9]); + expect(out["[redacted]"]).toBe(9); + expect(Object.values(records[0]!.more as object).sort()).toEqual([6, 7, 8]); + }); + + it("keeps a literal [truncated] key apart from the breadth marker", () => { + const { logger, records } = createTestLogger(); + const wide: Record = { "[truncated]": "real" }; + for (let i = 0; i < 1500; i++) wide[`k${i}`] = i; + logger.info({ wide }, "m"); + const out = records[0]!.wide as Record; + expect(out["[truncated]"]).toBe("real"); + expect(Object.values(out)).toContain(true); + }); +}); + +describe("value conversion", () => { + it("strips the query and userinfo of a URL value", () => { + const { logger, records } = createTestLogger(); + logger.info({ target: new URL("https://jane:pw@api.example.com/v1/x?token=SENTINEL-Q") }, "m"); + expect(records[0]).toMatchObject({ target: "https://api.example.com/v1/x" }); + }); + + it("scrubs email shapes from a URL value", () => { + const { logger, records } = createTestLogger(); + logger.info( + { + page: new URL("https://x.io/residents/jane@example.com/statements"), + mail: new URL("mailto:jane@example.com"), + }, + "m", + ); + expect(records[0]).toMatchObject({ + page: "https://x.io/residents/[redacted]/statements", + mail: "mailto:[redacted]", + }); + }); + + it("logs a Buffer as its length", () => { + const { logger, records } = createTestLogger(); + logger.info({ body: Buffer.from("first_name=Jane") }, "m"); + expect(records[0]).toMatchObject({ body: { bytes: 15 } }); + }); + + it("walks the result of toJSON and keeps Date and Decimal-like values", () => { + const when = new Date("2026-01-01T00:00:00Z"); + const money = { toJSON: () => "12.3400" }; + const payload = { toJSON: () => ({ contactEmail: VALUE, status: "ok" }) }; + const { logger, records } = createTestLogger(); + logger.info({ when, money, payload }, "m"); + expect(JSON.stringify(records[0])).not.toContain(VALUE); + expect(records[0]).toMatchObject({ + when: when.toISOString(), + money: "12.3400", + payload: { status: "ok" }, + }); + }); + + it("walks class instances", () => { + class Applicant { + ssn = VALUE; + name = "n"; + } + const { logger, records } = createTestLogger(); + logger.info({ a: new Applicant() }, "m"); + expect(JSON.stringify(records[0])).not.toContain(VALUE); + expect(records[0]).toMatchObject({ a: { name: "n" } }); + }); + + // WHY: a Prisma Decimal logs as the string its toJSON returns; depth must not cost it its value. + it("keeps a primitive toJSON result at depth 9", () => { + const decimal = { toJSON: () => "12.5000" }; + let nested: Record = { amount: decimal }; + for (let i = 0; i < 7; i++) nested = { d: nested }; + const { logger, records } = createTestLogger(); + logger.info({ d: nested }, "m"); + expect(JSON.stringify(records[0])).toContain('"amount":"12.5000"'); + }); +}); diff --git a/src/__tests__/log-safely.test.ts b/src/__tests__/log-safely.test.ts new file mode 100644 index 0000000..83bc494 --- /dev/null +++ b/src/__tests__/log-safely.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it, vi } from "vitest"; +import { diagLogger, log } from "../log-safely.js"; + +// OTel's diagnostics carry exporter errors and URLs; they must go through the redacting logger +// rather than straight to the console. +describe("diagLogger", () => { + it("routes each diag level to the telemetry logger, keeping only an Error argument", () => { + const warn = vi.spyOn(log, "warn").mockImplementation(() => undefined); + const trace = vi.spyOn(log, "trace").mockImplementation(() => undefined); + try { + const err = new Error("export failed"); + diagLogger.warn("exporter failed", { url: "https://x?key=k" }, err); + diagLogger.verbose("detail", "extra"); + expect(warn).toHaveBeenCalledWith({ event: "otel_diag", err }, "exporter failed"); + expect(trace).toHaveBeenCalledWith({ event: "otel_diag" }, "detail"); + } finally { + warn.mockRestore(); + trace.mockRestore(); + } + }); + + it("never throws when the logger does", () => { + const error = vi.spyOn(log, "error").mockImplementation(() => { + throw new Error("stream gone"); + }); + try { + expect(() => diagLogger.error("boom")).not.toThrow(); + } finally { + error.mockRestore(); + } + }); +}); diff --git a/src/__tests__/log-scrub-equivalence.test.ts b/src/__tests__/log-scrub-equivalence.test.ts new file mode 100644 index 0000000..db7fce2 --- /dev/null +++ b/src/__tests__/log-scrub-equivalence.test.ts @@ -0,0 +1,218 @@ +import { describe, expect, it } from "vitest"; +import { scrubErrorText, scrubMessage, scrubStack } from "../log-scrub.js"; +import { stripQuery } from "../redact.js"; +import { samples } from "./scrub-samples.js"; + +// The scrub written plainly: correct, but quadratic on long runs. Short inputs keep it fast here. +const REFERENCE = { + ssn: /\b\d{3}-\d{2}-\d{4}\b/g, + email: /[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}\b/g, + digits: /(?"; +const isSpace = (char: string) => /\s/.test(char); + +/** The end of the scheme-character run at `i`, when it holds a letter; else -1. */ +function schemeRun(token: string, i: number): number { + let run = i; + while (run < token.length && SCHEME_CHAR.test(token[run]!)) run++; + return /[a-z]/i.test(token.slice(i, run)) ? run : -1; +} + +/** The first URL at or after `from`, as [start, authority start]. */ +function nextUrlStart(token: string, from: number): [number, number] | undefined { + for (let i = from; i < token.length; i++) { + const before = token[i - 1]; + if (i === 0 || !SCHEME_CHAR.test(before!)) { + const run = schemeRun(token, i); + if (run !== -1 && token.startsWith("://", run)) return [i, run + 3]; + if (run !== -1 && token.startsWith(":\\/\\/", run)) return [i, run + 5]; + } + if (i === 0 || !NOT_A_DELIMITER.test(before!)) { + const run = schemeRun(token, i); + if (run !== -1 && token.startsWith(":/", run) && token[run + 2] !== "/") return [i, run + 2]; + if (token.startsWith("//", i)) return [i, i + 2]; + } + } + return undefined; +} + +/** A quote at the token's end or before whitespace or a JSON or list delimiter. */ +function isQuoteEnd(token: string, i: number): boolean { + if (token[i] !== '"' && token[i] !== "'") return false; + return i + 1 === token.length || /[\s,;:}\])>"'&]/.test(token[i + 1]!); +} + +function firstIndex(token: string, from: number, ends: (i: number) => boolean): number { + let i = from; + while (i < token.length && !ends(i)) i++; + return i; +} + +/** A bracketed host, or a non-empty host with no `:` or bracket, then nothing or `:` and digits. */ +function isHostAndPort(head: string): boolean { + if (head.startsWith("[")) { + const close = head.indexOf("]"); + return close !== -1 && /^(:\d*)?$/.test(head.slice(close + 1)); + } + const colon = head.indexOf(":"); + const host = colon === -1 ? head : head.slice(0, colon); + const port = colon === -1 ? "" : head.slice(colon + 1); + return host !== "" && !host.includes("[") && !host.includes("]") && /^\d*$/.test(port); +} + +/** The userinfo's length at the start of a URL's authority text. */ +function userinfoLength(url: string): number { + const at = url.indexOf("@"); + if (at === -1) return 0; + const delimiter = url.search(/[/?#\\]/); + if (delimiter !== -1 && delimiter < at) { + const head = url.slice(0, delimiter); + if (head.includes(":") || head.startsWith("[")) { + const evidence = + head.startsWith("[") || + url[delimiter] === "/" || + (url[delimiter] === "\\" + ? url[delimiter + 1] === "/" + : /[=/]/.test(url.slice(delimiter, at))); + if (evidence && isHostAndPort(head)) return 0; + } else if (url.slice(0, at).includes("/")) { + return 0; + } + } + const after = url.slice(at + 1).search(/[/?#\\]/); + return url.lastIndexOf("@", after === -1 ? url.length - 1 : at + after) + 1; +} + +function referenceUrlToken(token: string): string { + let next = nextUrlStart(token, 0); + if (!next) return token; + let out = token.slice(0, next[0]); + while (next) { + const [start, authority] = next; + const userinfoEnd = firstIndex( + token, + authority, + (i) => + HARD_END.includes(token[i]!) || + (isQuoteEnd(token, i) && !(token[i] === "'" && ")'".includes(token[i + 1] ?? "-"))), + ); + const host = authority + userinfoLength(token.slice(authority, userinfoEnd)); + const end = firstIndex( + token, + host, + (i) => isSpace(token[i]!) || HARD_END.includes(token[i]!) || isQuoteEnd(token, i), + ); + let following = nextUrlStart(token, host); + const url = token.slice(host, following ? Math.min(following[0], end) : end); + const cut = url.search(/[?#;]/); + out += token.slice(start, authority); + let resume = host; + if (cut !== -1) { + out += url.slice(0, cut); + resume = firstIndex(token, host + cut, (i) => isSpace(token[i]!)); + if (following && following[0] < resume) following = nextUrlStart(token, resume); + } + out += token.slice(resume, following ? following[0] : token.length); + next = following; + } + return out; +} + +/** A URL value: userinfo after a leading `scheme:` chain and its slashes, then the query. */ +function referenceStripQuery(value: string): string { + let i = 0; + while (i < value.length && isSpace(value[i]!)) i++; + let authority = -1; + if (value.startsWith("//", i)) { + authority = i + 2; + } else { + let schemes = 0; + for (let m = /^[0-9+.-]*[a-z][a-z0-9+.-]*:/i.exec(value.slice(i)); m; schemes++) { + i += m[0].length; + m = /^[0-9+.-]*[a-z][a-z0-9+.-]*:/i.exec(value.slice(i)); + } + const slashes = ["//", "/", "\\/\\/"].find((lead) => value.startsWith(lead, i)); + if (schemes > 0 && slashes) authority = i + slashes.length; + } + const url = + authority === -1 + ? value + : value.slice(0, authority) + value.slice(authority + userinfoLength(value.slice(authority))); + const cut = url.search(/[?#;]/); + return cut === -1 ? url : url.slice(0, cut); +} + +function referenceMessage(text: string): string { + return text + .replace(/[^ \n\r]+/g, referenceUrlToken) + .replace(REFERENCE.path, "$1$2") + .replace(REFERENCE.ssn, "[redacted]") + .replace(REFERENCE.email, "[redacted]"); +} + +function referenceStack(stack: string): string { + const match = REFERENCE.frame.exec(stack); + const frames = match ? stack.slice(match.index) : ""; + const head = match ? stack.slice(0, match.index) : stack; + return `${referenceMessage(head).replace(REFERENCE.digits, "[redacted]")}${frames}`; +} + +// WHY: the linear rewrite must redact exactly what the original patterns redacted, no less. +describe("linear scrub matches the original patterns", () => { + it("on 20,000 generated strings", () => { + for (const input of samples(20_000)) { + expect(scrubMessage(input), JSON.stringify(input)).toBe(referenceMessage(input)); + expect(scrubErrorText(input), JSON.stringify(input)).toBe( + referenceMessage(input).replace(REFERENCE.digits, "[redacted]"), + ); + expect(scrubStack(input), JSON.stringify(input)).toBe(referenceStack(input)); + expect(stripQuery(input), JSON.stringify(input)).toBe(referenceStripQuery(input)); + } + }); + + it.each([ + "a@b.com.x@c.com", + "a@b.com@c.com", + "x?y=http://h?z", + "1http://x?q", + "123://x?q http://y?z", + "jane@example.com+bob@example.org", + "foo\n\n at bar", + "x \n at y", + "x\nat jane@example.com", + "x\n at jane@example.com", + "x \t\n\n at jane@x.io 12345678", + "postgres://u:pw1@pw2.ab@db:5432/x", + "postgres://u:p?ss@db/x#y", + "postgres://u:p/ss@db/x", + "https://host:8080/@handle", + 'x="//u:p@h/x?q",y=http://a:b@c', + "a://b://c@d?e//f:g@h", + '{"u":"https://h","e":"a@b.io"}', + "https://ghp_ab#c@github.com/x", + "http://[::1]:80/p?u=a@b.com", + "https://h:/p?u=a@b", + "postgres://u:p,//x@db/x", + "postgres://u:ab://c@db/x", + " jdbc:pg://u:p@h/x?q", + "postgres://u:it's@db/x", + '{"d":"pg://u:p\\"x@db/x"}', + "postgres://admin:2024#S@db/x", + "https://h/x;j=S", + "https:/u:p@h/x", + "pg:\\/\\/u:p@db\\/x", + "pg://u:p\tq@db/x", + "pg://app:\\abc/def@db/x", + "pg://app:c')%22'5@db/x", + ])("on %j", (input) => { + expect(scrubMessage(input)).toBe(referenceMessage(input)); + expect(scrubStack(input)).toBe(referenceStack(input)); + expect(stripQuery(input)).toBe(referenceStripQuery(input)); + }); +}); diff --git a/src/__tests__/log-scrub-linear.test.ts b/src/__tests__/log-scrub-linear.test.ts new file mode 100644 index 0000000..e05f0cb --- /dev/null +++ b/src/__tests__/log-scrub-linear.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; +import { scrubErrorText, scrubMessage, scrubStack } from "../log-scrub.js"; +import { stripQuery } from "../redact.js"; +import { createTestLogger } from "../testing.js"; + +const SIZE = 200_000; +/** Generous: a linear scrub of 200 KB takes a few milliseconds; a quadratic one takes minutes. */ +const BOUND_MS = 100; + +function repeat(unit: string, size = SIZE): string { + return unit.repeat(Math.ceil(size / unit.length)).slice(0, size); +} + +const BASE64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +/** Long runs that hold no complete email, URL, SSN or stack frame, each a backtracking trap. */ +const ADVERSARIAL: Record = { + letters: repeat("a"), + dotted: repeat("a."), + base64: Array.from({ length: SIZE }, (_, i) => BASE64[(i * 7919) % 64]).join(""), + "email-ish without @": repeat("a.b-c_d%e+"), + "repeated a@": repeat("a@"), + "repeated a@a.": repeat("a@a."), + "domain without a TLD": `x@${repeat("a.a1")}`, + "http-ish": repeat("http"), + "scheme colons": repeat("http:"), + "repeated a://": repeat("a://"), + "digit-letter": repeat("1a"), + digits: repeat("1"), + "slash path": repeat("/a"), + "spaced paths": repeat(" /a"), + newlines: repeat("\n"), + "newline-space": repeat("\n "), + spaces: repeat(" "), + "hyphenated runs": repeat("a-1."), + "many @, // and ://": repeat('u:p@//a://b@:1/?@"//'), + "userinfo with no @": `x://u:${repeat("p/")}`, + "port-like digits": `x://u:${repeat("1")}`, + "@ runs after a delimiter": `x://u:p/${repeat("@a")}`, + 'dense @ // :// : [ "': repeat('[u:p@//a:"b://[:1@"//:[@,//x?#'), + "nested // with no @": `x://${repeat("u:p,//")}`, + "heads before one far @": `x://${repeat(",//a:1/,//b?")}@h`, + "quotes, backslashes and tabs": repeat(`x://u:"p'\\/\\/@\t:1#=;a:/`), + "soft spaces before one far @": `x://${repeat("u:p\t\u00a0")}@h`, +}; + +const MILLION = 1_000_000; +const DENSE_MILLION = '[u:p@//a:"b://[:1@"//:[@,//x?#\'\\/\t;h:/' + .repeat(Math.ceil(MILLION / 20)) + .slice(0, MILLION); + +/** The fastest of three runs: a GC pause or a busy CI worker slows one run, never all three. */ +function bestOfThree(fn: () => unknown): number { + let best = Infinity; + for (let i = 0; i < 3; i++) { + const start = performance.now(); + fn(); + best = Math.min(best, performance.now() - start); + } + return best; +} + +// WHY: the scrubbers run on every log line, with telemetry on or off. A regex that rescans a long +// run from every start position blocks the event loop for seconds on one large value. +describe("scrubbing stays linear on long strings", () => { + const scrubbers = { + scrubMessage, + scrubErrorText, + scrubStack: (s: string) => scrubStack(s), + stripQuery, + }; + for (const [name, scrub] of Object.entries(scrubbers)) { + it.each(Object.entries(ADVERSARIAL))(`${name}: %s`, (_, input) => { + expect(bestOfThree(() => scrub(input))).toBeLessThan(BOUND_MS); + }); + } + + it("scrubs a 1M-character dense input in linear time", () => { + for (const scrub of Object.values(scrubbers)) { + expect(bestOfThree(() => scrub(DENSE_MILLION))).toBeLessThan(5 * BOUND_MS); + } + }); + + it("logs a 200 KB message, field and Error without stalling", () => { + const { logger, records } = createTestLogger(); + const big = ADVERSARIAL.letters!; + expect(bestOfThree(() => logger.info(big))).toBeLessThan(5 * BOUND_MS); + expect(bestOfThree(() => logger.info({ detail: big }, "x"))).toBeLessThan(5 * BOUND_MS); + expect(bestOfThree(() => logger.error({ err: new Error(big) }, "x"))).toBeLessThan( + 5 * BOUND_MS, + ); + expect(records).toHaveLength(9); + }); +}); diff --git a/src/__tests__/log-scrub-userinfo.test.ts b/src/__tests__/log-scrub-userinfo.test.ts new file mode 100644 index 0000000..d716c2f --- /dev/null +++ b/src/__tests__/log-scrub-userinfo.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { scrubMessage } from "../log-scrub.js"; +import { scrubAttributes, scrubSpanName, stripQuery } from "../redact.js"; +import { + MESSAGE_USERINFO_CASES, + SPAN_NAME_CASES, + URL_ATTRIBUTE_CASES, + URL_USERINFO_CASES, +} from "./userinfo-cases.js"; + +// WHY: a DSN password often holds an unencoded `@`, `?`, `#` or `/`; a scrub that stops early +// exports the rest of it. +describe("URL userinfo", () => { + it.each(MESSAGE_USERINFO_CASES)("scrubMessage(%j) is %j", (input, expected) => { + expect(scrubMessage(input)).toBe(expected); + }); + + it.each(URL_USERINFO_CASES)("stripQuery(%j) is %j", (input, expected) => { + expect(stripQuery(input)).toBe(expected); + }); + + it.each(SPAN_NAME_CASES)("scrubSpanName(%j) is %j", (input, expected) => { + expect(scrubSpanName(input, undefined)).toBe(expected); + }); + + it.each(URL_ATTRIBUTE_CASES)("scrubAttributes %s %j is %j", (key, value, expected) => { + expect(scrubAttributes({ [key]: value })[key]).toBe(expected); + }); +}); diff --git a/src/__tests__/log-scrub.test.ts b/src/__tests__/log-scrub.test.ts new file mode 100644 index 0000000..f895d0a --- /dev/null +++ b/src/__tests__/log-scrub.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { scrubErrorText, scrubMessage, scrubStack } from "../log-scrub.js"; + +describe("scrubMessage", () => { + it("replaces SSN and email shapes", () => { + expect(scrubMessage("jane@example.com 123-45-6789")).toBe("[redacted] [redacted]"); + }); + + // WHY: a failed fetch quotes its URL, and the query is what staff searched for. + it("strips the query and fragment from a URL inside a message", () => { + expect(scrubMessage("fetch https://api.test/v1/tenants?search=Jane%20Doe#x failed")).toBe( + "fetch https://api.test/v1/tenants failed", + ); + }); + + it("strips the query from an absolute path inside a message", () => { + expect(scrubMessage("GET /v1/tenants?search=Jane%20Doe returned 500")).toBe( + "GET /v1/tenants returned 500", + ); + }); + + // WHY: a connection error quotes its DSN, credentials included. + it("strips the userinfo from a URL inside a message, whatever the scheme's case", () => { + expect(scrubMessage("connect postgres://app:s3cret@db:5432/x failed")).toBe( + "connect postgres://db:5432/x failed", + ); + expect(scrubMessage("GET HTTPS://user:pass@host/p?q=1 failed")).toBe( + "GET HTTPS://host/p failed", + ); + expect(scrubMessage("fetch https://host/p failed")).toBe("fetch https://host/p failed"); + }); + + it("leaves an email or a scheme-less user@host to the other rules", () => { + expect(scrubMessage("sent to a@b.com")).toBe("sent to [redacted]"); + expect(scrubMessage("ssh deploy@buildbox failed")).toBe("ssh deploy@buildbox failed"); + }); + + it("strips URL userinfo from error text and a stack's header", () => { + expect(scrubErrorText("redis://:pw@cache:6379 refused")).toBe("redis://cache:6379 refused"); + expect(scrubStack("Error: amqp://u:p@mq/v\n at f (x.js:1:1)")).toBe( + "Error: amqp://mq/v\n at f (x.js:1:1)", + ); + }); + + it("leaves a question mark that is not part of a URL", () => { + expect(scrubMessage("why? because")).toBe("why? because"); + }); +}); diff --git a/src/__tests__/logger-first-arg.test.ts b/src/__tests__/logger-first-arg.test.ts new file mode 100644 index 0000000..95ca507 --- /dev/null +++ b/src/__tests__/logger-first-arg.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from "vitest"; +import { createTestLogger } from "../testing.js"; + +// The first argument is merged with the logger's own fields; it must reach the redactor in the +// shape JSON would give it, or a toJSON that hides a secret is bypassed. +describe("first-argument object", () => { + it("honours a class's toJSON, so the fields it hides stay hidden", () => { + class Credentials { + privateKey = "SENTINEL-PK"; + keyId = "k-1"; + toJSON() { + return { keyId: this.keyId }; + } + } + const { logger, records } = createTestLogger(); + logger.info(new Credentials(), "loaded"); + expect(JSON.stringify(records[0])).not.toContain("SENTINEL-PK"); + expect(records[0]).not.toHaveProperty("privateKey"); + expect(records[0]).toMatchObject({ keyId: "k-1", msg: "loaded" }); + expect(records[0]).toHaveProperty("app"); + }); + + it("logs a Buffer as its length, not as indexed bytes", () => { + const { logger, records } = createTestLogger(); + logger.info(Buffer.from("abc"), "m"); + expect(records[0]).toMatchObject({ bytes: 3, msg: "m" }); + expect(records[0]).not.toHaveProperty("0"); + expect(records[0]).toHaveProperty("app"); + }); + + it("logs a URL under value as a scrubbed string without its query", () => { + const { logger, records } = createTestLogger(); + logger.info(new URL("https://h.test/a/jane@example.com?token=SENTINEL-Q"), "m"); + expect(JSON.stringify(records[0])).not.toMatch(/jane@example\.com|SENTINEL-Q/); + expect(records[0]).toMatchObject({ value: "https://h.test/a/[redacted]", msg: "m" }); + expect(records[0]).toHaveProperty("app"); + }); + + it("converts before reading msg, so a msg property cannot bypass toJSON", () => { + class Hide { + privateKey = "SENTINEL-PK"; + msg = "hello"; + toJSON() { + return { ok: 1 }; + } + } + const { logger, records } = createTestLogger(); + logger.info(new Hide(), "m"); + logger.info(new Hide()); + for (const record of records) expect(JSON.stringify(record)).not.toContain("SENTINEL-PK"); + }); + + it("derives msg from an error that toJSON returns", () => { + const { logger, records } = createTestLogger(); + logger.info({ toJSON: () => ({ err: new Error("") }) }); + expect(records[0]).toMatchObject({ msg: "Error" }); + }); + + it("logs arrays, dates, typed arrays and maps the way JSON would see them", () => { + const { logger, records } = createTestLogger(); + logger.info([1, "jane@example.com"], "a"); + logger.info(new Date(0), "d"); + logger.info(new Uint8Array([1, 2, 3]), "u"); + logger.info(new Map([["k", "v"]]), "m"); + expect(records[0]).toMatchObject({ value: [1, "[redacted]"] }); + expect(records[1]).toMatchObject({ value: "1970-01-01T00:00:00.000Z" }); + expect(records[2]).toMatchObject({ bytes: 3 }); + expect(records[2]).not.toHaveProperty("0"); + expect(records[3]).toMatchObject({ value: { k: "v" } }); + }); + + it("never throws from child() when the bindings' toJSON throws", () => { + const { logger, records } = createTestLogger(); + const bindings = { + toJSON() { + throw new Error("boom"); + }, + }; + expect(() => logger.child(bindings).info("c")).not.toThrow(); + expect(records[0]).toMatchObject({ log_redaction_failed: true, msg: "c" }); + }); + + it("follows a toJSON chain only one level, so an endless chain cannot recurse", () => { + class Chain { + toJSON(): unknown { + return new Chain(); + } + } + const { logger, records } = createTestLogger(); + expect(() => logger.info(new Chain(), "c")).not.toThrow(); + expect(() => logger.child(new Chain()).info("d")).not.toThrow(); + expect(records).toHaveLength(2); + }); + + it("keeps child bindings whose toJSON returns a date", () => { + const { logger, records } = createTestLogger(); + logger.child({ toJSON: () => new Date(0) } as object).info("c"); + expect(records[0]).toMatchObject({ value: "1970-01-01T00:00:00.000Z" }); + }); + + it("converts child bindings before renaming reserved keys", () => { + const { logger, records } = createTestLogger(); + logger.child({ toJSON: () => ({ level: "x", app: "spoof" }) } as object).info("c"); + expect(records[0]).toMatchObject({ "field.level": "x", "field.app": "spoof" }); + expect(records[0]!.level).not.toBe("x"); + }); +}); + +// An empty message would print `msg: ""`; the error type is the next most useful thing to search. +describe("an error with an empty message", () => { + class LockLost extends Error {} + + it("falls back to the error type for msg", () => { + for (const call of [ + (l: ReturnType["logger"]) => l.error(new LockLost("")), + (l: ReturnType["logger"]) => l.error({ err: new LockLost("") }), + (l: ReturnType["logger"]) => l.error({ e: new LockLost("") }), + ]) { + const t = createTestLogger(); + call(t.logger); + expect(t.records[0]!.msg).toBe("LockLost"); + } + }); + + it("keeps the caller's message when one is given", () => { + const { logger, records } = createTestLogger(); + logger.error({ err: new LockLost("") }, "lock lost"); + expect(records[0]!.msg).toBe("lock lost"); + }); +}); diff --git a/src/__tests__/logger.test.ts b/src/__tests__/logger.test.ts new file mode 100644 index 0000000..d0e6428 --- /dev/null +++ b/src/__tests__/logger.test.ts @@ -0,0 +1,269 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { context } from "@opentelemetry/api"; +import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks"; +import { BasicTracerProvider } from "@opentelemetry/sdk-trace-base"; +import { announceLogLevel, createLogger, resolveLogLevel } from "../logger.js"; +import { createTestLogger } from "../testing.js"; + +function capturing(): { lines: Record[]; write: (l: string) => void } { + const lines: Record[] = []; + return { lines, write: (l: string) => void lines.push(JSON.parse(l)) }; +} + +describe("thrown primitives", () => { + it("scrubs a string logged as err", () => { + const { logger, records } = createTestLogger(); + logger.error({ err: "jane@example.com 123-45-6789" }, "x"); + expect(JSON.stringify(records[0])).not.toMatch(/jane@example\.com|123-45-6789/); + }); +}); + +describe("createLogger", () => { + afterEach(() => { + context.disable(); + vi.unstubAllEnvs(); + }); + + it("redacts PII at the top level and in nested objects", () => { + const { logger, records } = createTestLogger(); + logger.info( + { + ssn: "123-45-6789", + tenant: { accountNumber: "000123456789", email: "jane@example.com" }, + application: { applicant: { phone: "555-0100" } }, + req: { headers: { authorization: "Bearer abc", cookie: "sid=1" } }, + tenantId: "t-1", + }, + "applied", + ); + expect(JSON.stringify(records[0])).not.toMatch( + /123-45-6789|000123456789|jane@example\.com|555-0100|Bearer abc|sid=1/, + ); + expect(records[0]).toMatchObject({ level: "info", msg: "applied", tenantId: "t-1" }); + }); + + it("stamps trace_id and span_id from the active span", () => { + context.setGlobalContextManager(new AsyncLocalStorageContextManager().enable()); + const tracer = new BasicTracerProvider().getTracer("t"); + const { logger, records } = createTestLogger(); + tracer.startActiveSpan("op", (span) => { + logger.info("inside"); + span.end(); + expect(records[0]).toMatchObject({ + trace_id: span.spanContext().traceId, + span_id: span.spanContext().spanId, + }); + }); + logger.info("outside"); + expect(records[1]).not.toHaveProperty("trace_id"); + }); + + describe("children", () => { + it("redacts bindings and keeps chaining", () => { + const { logger, records } = createTestLogger(); + const child = logger.child({ tenantId: "t-1", applicantEmail: "a@b.co" }); + child.child({ requestId: "r-1", nested: { ssn: "SENTINEL-G" } }).info("x"); + expect(records[0]).toMatchObject({ tenantId: "t-1", requestId: "r-1" }); + expect(JSON.stringify(records[0])).not.toMatch(/SENTINEL-G|a@b\.co/); + }); + + it("inherits level and accumulates msgPrefix in grandchildren", () => { + const { logger, records } = createTestLogger(); + const a = logger.child({}, { level: "warn", msgPrefix: "[A] " }); + const b = a.child({}, { msgPrefix: "[B] " }); + b.info("dropped"); + b.warn("x"); + expect(records).toHaveLength(1); + expect(records[0]!.msg).toBe("[A] [B] x"); + }); + }); + + // Railway and the log search key on level, time, msg, app and env; a caller field of the same + // name would overwrite the logger's value when the line is parsed. + describe("reserved fields", () => { + it("renames a caller's level, time, msg, app and env", () => { + const { logger, records } = createTestLogger(); + logger.warn({ level: "L", time: "T", msg: "M", app: "A", env: "E", ok: 1 }, "real"); + expect(records[0]).toMatchObject({ + level: "warn", + msg: "real", + "field.level": "L", + "field.time": "T", + "field.msg": "M", + "field.app": "A", + "field.env": "E", + ok: 1, + }); + expect(records[0]!.time).not.toBe("T"); + expect(records[0]!.app).not.toBe("A"); + }); + + it("renames reserved child bindings", () => { + const { logger, records } = createTestLogger(); + logger.child({ level: "L", app: "A" }).info("x"); + expect(records[0]).toMatchObject({ level: "info", "field.level": "L", "field.app": "A" }); + expect(records[0]!.app).not.toBe("A"); + }); + }); + + describe("msg", () => { + const prismaErr = () => + Object.assign(new Error("Unique constraint failed: email=alice@example.com"), { + name: "PrismaClientKnownRequestError", + code: "P2002", + }); + + it("keeps raw error text out of msg when no message is given", () => { + for (const call of [ + (l: ReturnType["logger"]) => l.error(prismaErr()), + (l: ReturnType["logger"]) => l.error({ err: prismaErr() }), + (l: ReturnType["logger"]) => l.error({ error: prismaErr() }), + ]) { + const t = createTestLogger(); + call(t.logger); + expect(JSON.stringify(t.records[0])).not.toContain("alice@example.com"); + expect(t.records[0]!.msg).toBe("PrismaClientKnownRequestError P2002"); + } + }); + + it("scrubs the msg pino derives from a non-Error err object", () => { + const { logger, records } = createTestLogger(); + logger.error({ err: { message: "failed for jane@example.com", detail: "SENTINEL-D" } }); + expect(records[0]!.msg).toBe("failed for [redacted]"); + expect(JSON.stringify(records[0])).not.toMatch(/jane@example\.com|SENTINEL-D/); + }); + + it("scrubs a msg field", () => { + const { logger, records } = createTestLogger(); + logger.info({ msg: "sent to jane@example.com", ok: 1 }); + expect(records[0]).toMatchObject({ msg: "sent to [redacted]", ok: 1 }); + }); + + it("keeps a plain error's message as msg", () => { + const { logger, records } = createTestLogger(); + logger.error(new Error("queue is down")); + expect(records[0]!.msg).toBe("queue is down"); + }); + + it("scrubs SSN and email shapes from msg but not other digits", () => { + const { logger, records } = createTestLogger(); + logger.info("applicant 123-45-6789 mail bob@x.io amount 1234567890 id 000123456789"); + expect(records[0]!.msg).toBe( + "applicant [redacted] mail [redacted] amount 1234567890 id 000123456789", + ); + }); + + it("redacts object format args and serializes Errors in them", () => { + const { logger, records } = createTestLogger(); + const err = Object.assign(new Error("for jane@example.com"), { body: "SENTINEL-B" }); + logger.info("obj %j err %o", { email: "SENTINEL-F1", ok: 1 }, err); + expect(JSON.stringify(records[0])).not.toMatch(/SENTINEL-F1|SENTINEL-B|jane@example\.com/); + expect(records[0]!.msg).toContain('"ok":1'); + }); + + // A format arg takes the log object's sanitizer, so an error under err/error/e is serialized. + it.each(["%j", "%o"])("serializes err, error and e inside a %s format arg", (fmt) => { + for (const key of ["err", "error", "e"]) { + const err = Object.assign(new Error("m"), { + body: "jane@example.com", + command: { args: ["SECRET_ARG"] }, + }); + const { logger, records } = createTestLogger(); + if (fmt === "%j") logger.info("ctx %j", { [key]: err }); + else logger.info("ctx %o", { [key]: err }); + expect(JSON.stringify(records[0])).not.toMatch(/jane@example\.com|SECRET_ARG/); + } + // An error-shaped plain object (a rethrown pg error, a provider reply) is serialized too. + const errorLike = { + name: "X", + message: "m to jane@example.com", + body: "jane@example.com", + detail: "SENT-DETAIL", + }; + const { logger, records } = createTestLogger(); + if (fmt === "%j") logger.info("ctx %j", { err: errorLike }); + else logger.info("ctx %o", { err: errorLike }); + const msg = String(records[0]!.msg); + expect(msg).not.toMatch(/jane@example\.com|SENT-DETAIL|"body"|"detail"/); + expect(msg).toContain('"message":"m to [redacted]"'); + }); + + it("scrubs a msg field that is renamed field.msg", () => { + const { logger, records } = createTestLogger(); + logger.error({ err: new Error("boom"), msg: "user jane.doe@example.com" }); + logger.info({ msg: "user jane.doe@example.com" }, "real message"); + expect(records[0]).toMatchObject({ msg: "boom", "field.msg": "user [redacted]" }); + expect(records[1]).toMatchObject({ msg: "real message", "field.msg": "user [redacted]" }); + }); + + it("serializes and scrubs Errors at any depth", () => { + const { logger, records } = createTestLogger(); + const nested = Object.assign(new Error("for jane@example.com"), { command: "SENTINEL-N" }); + logger.error({ wrapped: { cause: nested }, list: [nested] }, "f"); + const line = JSON.stringify(records[0]); + expect(line).not.toMatch(/jane@example\.com|SENTINEL-N/); + expect(records[0]).toMatchObject({ wrapped: { cause: { message: "for [redacted]" } } }); + }); + }); + + describe("caller input", () => { + // A caller reuses its context object across calls; the logger's own fields must not leak in. + it("leaves the caller's object unchanged", () => { + const { logger } = createTestLogger(); + const ctx = { tenantId: "t-1" }; + const reserved = { tenantId: "t-1", level: "x" }; + logger.info(ctx, "m"); + logger.info(reserved, "m"); + expect(ctx).toEqual({ tenantId: "t-1" }); + expect(reserved).toEqual({ tenantId: "t-1", level: "x" }); + }); + + // If the arguments cannot be inspected, nothing raw reaches msg: a format arg could carry PII. + it("logs a placeholder with no format args when the arguments cannot be inspected", () => { + const { logger, records } = createTestLogger(); + const hostile = { + get msg(): string { + throw new Error("no"); + }, + }; + const info = logger.info.bind(logger) as (o: object, m: string, ...a: unknown[]) => void; + expect(() => info(hostile, "for %s", "jane@example.com")).not.toThrow(); + expect(records[0]!.msg).toBe("[unloggable]"); + expect(JSON.stringify(records[0])).not.toContain("jane@example.com"); + }); + }); + + describe("LOG_LEVEL", () => { + it("accepts any case and rejects silent, which would hide fatal lines", () => { + expect(resolveLogLevel("DEBUG")).toEqual({ level: "debug" }); + expect(resolveLogLevel(" Warn ")).toEqual({ level: "warn" }); + expect(resolveLogLevel("silent")).toEqual({ level: "info", invalid: "silent" }); + expect(resolveLogLevel("bogus")).toEqual({ level: "info", invalid: "bogus" }); + expect(resolveLogLevel(undefined)).toEqual({ level: "info" }); + }); + + // The warning waits for the process name, so it is searchable by app like every other line. + it("holds the invalid-level warning until announced, then emits it once", () => { + vi.stubEnv("LOG_LEVEL", "bogus"); + const { lines, write } = capturing(); + let app = "unknown"; + const logger = createLogger(() => app, { write }); + expect(lines).toHaveLength(0); + app = "worker"; + announceLogLevel(logger); + announceLogLevel(logger); + logger.debug("hidden"); + logger.info("shown"); + expect(lines.map((l) => l.msg)).toEqual(["invalid LOG_LEVEL, using info", "shown"]); + expect(lines[0]).toMatchObject({ level: "warn", requested: "bogus", app: "worker" }); + }); + + it("emits the held warning before the first line when never announced", () => { + vi.stubEnv("LOG_LEVEL", "silent"); + const { lines, write } = capturing(); + const logger = createLogger(() => "t", { write }); + logger.child({ component: "c" }).info("first"); + expect(lines.map((l) => l.msg)).toEqual(["invalid LOG_LEVEL, using info", "first"]); + }); + }); +}); diff --git a/src/__tests__/metric-views.test.ts b/src/__tests__/metric-views.test.ts new file mode 100644 index 0000000..f47d2ca --- /dev/null +++ b/src/__tests__/metric-views.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { + AggregationTemporality, + InMemoryMetricExporter, + MeterProvider, + PeriodicExportingMetricReader, +} from "@opentelemetry/sdk-metrics"; +import { metricViews } from "../metric-views.js"; +import type { MetricViewOptions } from "../options.js"; + +const APP_VIEWS: MetricViewOptions = { + instrumentPrefix: "app.", + allowedAttributes: ["queue", "outcome"], +}; + +async function exportedAttributes( + instrument: string, + attributes: Record, + options: MetricViewOptions | undefined, +) { + const exporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE); + const reader = new PeriodicExportingMetricReader({ exporter, exportIntervalMillis: 60_000 }); + const provider = new MeterProvider({ readers: [reader], views: metricViews(options) }); + provider.getMeter("app-worker").createCounter(instrument).add(1, attributes); + await reader.forceFlush(); + const metric = exporter + .getMetrics() + .flatMap((r) => r.scopeMetrics.flatMap((s) => s.metrics)) + .find((m) => m.descriptor.name === instrument); + await provider.shutdown(); + return metric?.dataPoints[0]?.attributes; +} + +// WHY: a metric label is a time series per value. An identifier or a message on an app +// instrument would explode cardinality and put personal data in Prometheus, which keeps it. +describe("metricViews", () => { + it("exports only allowlisted labels from the app's prefixed instruments", async () => { + const attributes = await exportedAttributes( + "app.worker.job.duration", + { + queue: "late-fees", + outcome: "failed", + tenantId: "tenant-123", + "error.message": "duplicate email jane@example.com", + }, + APP_VIEWS, + ); + expect(attributes).toEqual({ queue: "late-fees", outcome: "failed" }); + }); + + it("leaves instruments outside the prefix to their own instrumentation", async () => { + const attributes = await exportedAttributes( + "http.server.request.duration", + { "http.route": "/v1/tenants", "server.address": "api" }, + APP_VIEWS, + ); + expect(attributes).toEqual({ "http.route": "/v1/tenants", "server.address": "api" }); + }); + + it("keeps every attribute when the app sets no metricViews", async () => { + const attributes = await exportedAttributes( + "app.worker.job.duration", + { queue: "late-fees", tenantId: "tenant-123" }, + undefined, + ); + expect(attributes).toEqual({ queue: "late-fees", tenantId: "tenant-123" }); + }); +}); diff --git a/src/__tests__/no-endpoint.test.ts b/src/__tests__/no-endpoint.test.ts new file mode 100644 index 0000000..f86bcbd --- /dev/null +++ b/src/__tests__/no-endpoint.test.ts @@ -0,0 +1,60 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const loaded = vi.hoisted(() => ({ modules: [] as string[] })); +const SDK_MODULES = vi.hoisted(() => [ + "@opentelemetry/sdk-node", + "@opentelemetry/sdk-metrics", + "@opentelemetry/sdk-trace-base", + "@opentelemetry/exporter-trace-otlp-proto", + "@opentelemetry/exporter-metrics-otlp-proto", + "@opentelemetry/exporter-logs-otlp-proto", + "@opentelemetry/instrumentation-http", + "@opentelemetry/instrumentation-undici", + "@opentelemetry/instrumentation-ioredis", + "@opentelemetry/instrumentation-pg", + "@opentelemetry/instrumentation-runtime-node", + "bullmq-otel", + "import-in-the-middle", +]); + +for (const name of SDK_MODULES) { + vi.doMock(name, () => { + loaded.modules.push(name); + return {}; + }); +} + +afterEach(async () => { + const { shutdownTelemetry } = await import("../sdk.js"); + await shutdownTelemetry(100); + vi.unstubAllEnvs(); +}); + +// WHY: a process with no endpoint (local runs, CI, an app before its stack is switched on) must +// not pay for loading the SDK, its exporters and instrumentations, whatever options it passes. +describe("with no endpoint", () => { + it("startTelemetry loads no SDK module, with every option set", async () => { + const telemetry = await import("../index.js"); + const config = await telemetry.startTelemetry({ + serviceName: "acp-api", + env: {}, + logScope: "acp", + metricViews: { instrumentPrefix: "acp.", allowedAttributes: ["queue"] }, + redactKeys: ["unitLabel"], + onError: () => undefined, + }); + telemetry.captureConsole(); + expect(config.enabled).toBe(false); + expect(telemetry.isTelemetryStarted()).toBe(false); + expect(loaded.modules).toEqual([]); + }); + + it("the register entry loads no SDK module", async () => { + vi.stubEnv("OTEL_EXPORTER_OTLP_ENDPOINT", ""); + vi.stubEnv("OTEL_SERVICE_NAME", "acp-worker"); + await import("../register.js"); + const { isTelemetryStarted } = await import("../sdk.js"); + expect(isTelemetryStarted()).toBe(false); + expect(loaded.modules).toEqual([]); + }); +}); diff --git a/src/__tests__/options.test.ts b/src/__tests__/options.test.ts new file mode 100644 index 0000000..1a046ee --- /dev/null +++ b/src/__tests__/options.test.ts @@ -0,0 +1,254 @@ +import { EventEmitter } from "node:events"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { InstrumentationBase } from "@opentelemetry/instrumentation"; +import { log as telemetryLog } from "../log-safely.js"; +import { scrubAttributes } from "../redact.js"; +import { EXEMPT_ATTRIBUTES } from "../app-keys.js"; +import { installFatalHandlers } from "../fatal.js"; +import { buildInstrumentations } from "../instrumentations.js"; +import { isSensitiveKey, resetSensitiveKeys } from "../log-redact.js"; +import { logScope, resetOptions } from "../options.js"; +import { shutdownTelemetry, startTelemetry } from "../sdk.js"; +import { log } from "../shared-log.js"; +import { createTestLogger } from "../testing.js"; + +class AppInstrumentation extends InstrumentationBase { + constructor() { + super("app-instrumentation", "1.0.0", {}); + } + protected init() { + return []; + } +} + +afterEach(async () => { + await shutdownTelemetry(100); + resetOptions(); + resetSensitiveKeys(); + vi.restoreAllMocks(); +}); + +describe("redactKeys", () => { + // WHY: an app logs its own PII fields under names the default patterns cannot know. + it("redacts an app key in any letter case on top of the defaults", async () => { + await startTelemetry({ serviceName: "api", env: {}, redactKeys: ["unitLabel"] }); + const { logger, records } = createTestLogger(); + logger.info({ unitLabel: "4B", UNITLABEL: "4C", ssn: "123-45-6789", queue: "q" }, "x"); + expect(records[0]).toMatchObject({ + unitLabel: "[redacted]", + UNITLABEL: "[redacted]", + ssn: "[redacted]", + queue: "q", + }); + }); + + it("never removes a default key", async () => { + await startTelemetry({ serviceName: "api", env: {}, redactKeys: ["unitLabel"] }); + expect(isSensitiveKey("email")).toBe(true); + expect(isSensitiveKey("taxId")).toBe(true); + }); +}); + +describe("instrumentations", () => { + it("appends the app's instrumentations to the package's own list", () => { + const app = new AppInstrumentation(); + const names = buildInstrumentations([], [app]).map((i) => i.instrumentationName); + expect(names).toEqual([ + "@opentelemetry/instrumentation-runtime-node", + "@opentelemetry/instrumentation-http", + "@opentelemetry/instrumentation-undici", + "@opentelemetry/instrumentation-ioredis", + "@opentelemetry/instrumentation-pg", + "app-instrumentation", + ]); + }); +}); + +describe("onError", () => { + function crash(): Error { + const target = new EventEmitter(); + vi.spyOn(log, "fatal").mockImplementation(() => undefined); + installFatalHandlers({ + target: target as unknown as Pick, + exit: () => undefined, + }); + const err = new Error("boom"); + target.emit("uncaughtException", err); + process.exitCode = undefined; + return err; + } + + // WHY: the app's error tracker (PostHog in Backbone) must see a crash even though the package + // knows nothing about it. + it("receives a fatal error, with the service name, when no report is given", async () => { + const onError = vi.fn(); + await startTelemetry({ serviceName: "api", env: {}, onError }); + const err = crash(); + expect(onError).toHaveBeenCalledWith(err, { service: "api" }); + }); + + it("never breaks the fatal path when it throws", async () => { + await startTelemetry({ + serviceName: "api", + env: {}, + onError: () => { + throw new Error("tracker down"); + }, + }); + expect(() => crash()).not.toThrow(); + }); +}); + +describe("a second startTelemetry call", () => { + // WHY: stdout redaction and error reporting must not differ between a local run (no endpoint) + // and a deployed one: the first call's options win in both. + it("keeps the first call's options, and warns, with export off", async () => { + const warn = vi.spyOn(telemetryLog, "warn").mockImplementation(() => undefined); + await startTelemetry({ serviceName: "api", env: {}, redactKeys: ["unitLabel"] }); + await startTelemetry({ serviceName: "api", env: {}, redactKeys: ["roomCode"] }); + expect(isSensitiveKey("unitLabel")).toBe(true); + expect(isSensitiveKey("roomCode")).toBe(false); + expect(warn).toHaveBeenCalledWith( + expect.objectContaining({ event: "telemetry_restart_ignored" }), + "telemetry_restart_ignored", + ); + }); + + it("does not warn when the same options are passed again", async () => { + const warn = vi.spyOn(telemetryLog, "warn").mockImplementation(() => undefined); + const options = { serviceName: "api", env: {}, redactKeys: ["unitLabel"] }; + await startTelemetry(options); + await startTelemetry({ ...options, redactKeys: ["unitLabel"] }); + expect(warn).not.toHaveBeenCalled(); + }); +}); + +describe("startTelemetry with bad options", () => { + // WHY: an untyped caller can pass anything; telemetry must never break the app's boot, and one + // bad key must not cost the app every good one. + it("keeps the valid redact keys and warns with only a bad entry's type", async () => { + const warn = vi.spyOn(telemetryLog, "warn").mockImplementation(() => undefined); + await expect( + startTelemetry({ + serviceName: "api", + env: {}, + redactKeys: ["unitLabel", 4111111111111111, { secret: "hunter2" }] as unknown as string[], + }), + ).resolves.toMatchObject({ enabled: false }); + expect(isSensitiveKey("unitLabel")).toBe(true); + const calls = warn.mock.calls.filter( + ([fields]) => (fields as { event?: string }).event === "invalid_redact_key", + ); + expect(calls).toEqual([ + [{ event: "invalid_redact_key", types: ["number", "object"] }, "invalid_redact_key"], + ]); + expect(JSON.stringify(warn.mock.calls)).not.toMatch(/hunter2|4111111111111111/); + }); + + it("leaves no option applied when a call cannot be read, and a later call applies in full", async () => { + const warn = vi.spyOn(telemetryLog, "warn").mockImplementation(() => undefined); + await expect( + startTelemetry({ + serviceName: "api", + env: {}, + logScope: "half", + redactKeys: ["roomCode"], + onError: 5 as unknown as () => void, + }), + ).resolves.toMatchObject({ enabled: false }); + expect(warn).toHaveBeenCalledWith( + expect.objectContaining({ event: "telemetry_start_failed" }), + "telemetry_start_failed", + ); + expect(logScope()).not.toBe("half"); + expect(isSensitiveKey("roomCode")).toBe(false); + + await startTelemetry({ serviceName: "api", env: {}, logScope: "acp", redactKeys: ["unitLabel"] }); + expect(logScope()).toBe("acp"); + expect(isSensitiveKey("unitLabel")).toBe(true); + expect(warn).not.toHaveBeenCalledWith( + expect.objectContaining({ event: "telemetry_restart_ignored" }), + expect.anything(), + ); + }); + + it("resolves for options that are not an object", async () => { + vi.spyOn(telemetryLog, "warn").mockImplementation(() => undefined); + await expect(startTelemetry(null as never)).resolves.toMatchObject({ enabled: false }); + }); +}); + +describe("redactKeys on spans", () => { + // WHY: an app's PII key must not leave on a span attribute either. The agent's layer-2 scrub + // does not know app keys, so layer 1 is the only place they are redacted. + it("redacts a span attribute named by an app key, whole or as its last segment", async () => { + await startTelemetry({ serviceName: "api", env: {}, redactKeys: ["unitLabel"] }); + expect( + scrubAttributes({ unitLabel: "4B", "acp.unit_label": "x", "acp.UNITLABEL": "4C", queue: "q" }), + ).toEqual({ unitLabel: "[redacted]", "acp.unit_label": "x", "acp.UNITLABEL": "[redacted]", queue: "q" }); + }); + + // WHY: a generic app key (a bare `name` for a person's name) must not wipe the OpenTelemetry + // attributes that traces are searched and grouped by. + it("keeps the listed semantic-convention attributes from a generic key", async () => { + await startTelemetry({ + serviceName: "api", + env: {}, + redactKeys: ["name", "route", "method", "id", "type"], + }); + const kept = { + "db.system.name": "postgresql", + "http.route": "/v1/tenants/:id", + "http.request.method": "GET", + "messaging.destination.name": "late-fees", + "bullmq.job.id": "42", + "error.type": "TimeoutError", + "exception.type": "Error", + }; + expect(scrubAttributes({ ...kept, "acp.tenant.name": "Jane", name: "Jane" })).toEqual({ + ...kept, + "acp.tenant.name": "[redacted]", + name: "[redacted]", + }); + }); + + it("keeps every listed attribute from keys built from their own last segments", async () => { + const keys = [...EXEMPT_ATTRIBUTES].map((name) => name.slice(name.lastIndexOf(".") + 1)); + await startTelemetry({ serviceName: "api", env: {}, redactKeys: keys }); + const attributes = Object.fromEntries([...EXEMPT_ATTRIBUTES].map((name) => [name, "v"])); + expect(scrubAttributes(attributes)).toEqual(attributes); + }); + + // WHY: an attribute the app sets under an OpenTelemetry namespace is still the app's data; only + // the exact names the instrumentations emit are exempt. + it("redacts an unlisted attribute under an OpenTelemetry namespace", async () => { + await startTelemetry({ serviceName: "api", env: {}, redactKeys: ["unitLabel", "name"] }); + expect( + scrubAttributes({ + "http.unitLabel": "4B", + "db.unitlabel": "4B", + "messaging.unitLabel": "4B", + "bullmq.job.tenant.name": "Jane", + "db.system.name": "postgresql", + }), + ).toEqual({ + "http.unitLabel": "[redacted]", + "db.unitlabel": "[redacted]", + "messaging.unitLabel": "[redacted]", + "bullmq.job.tenant.name": "[redacted]", + "db.system.name": "postgresql", + }); + }); + + // Prisma sets bare `name`, `method` and `model`; a whole-name app key matches them. + it("redacts Prisma's bare attributes under a whole-name key", async () => { + await startTelemetry({ serviceName: "api", env: {}, redactKeys: ["name", "model"] }); + expect(scrubAttributes({ name: "Tenant.findMany", model: "Tenant", method: "findMany" })).toEqual( + { name: "[redacted]", model: "[redacted]", method: "findMany" }, + ); + }); + + it("leaves span attributes alone when the app sets no keys", () => { + expect(scrubAttributes({ unitLabel: "4B" })).toEqual({ unitLabel: "4B" }); + }); +}); diff --git a/src/__tests__/otel-lockstep.test.ts b/src/__tests__/otel-lockstep.test.ts new file mode 100644 index 0000000..a07fa92 --- /dev/null +++ b/src/__tests__/otel-lockstep.test.ts @@ -0,0 +1,51 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +type Manifest = Partial>>; + +const manifest = JSON.parse( + readFileSync(new URL("../../package.json", import.meta.url), "utf8"), +) as Manifest; +const pins = Object.fromEntries( + Object.entries({ ...manifest.dependencies, ...manifest.devDependencies }).filter(([name]) => + name.startsWith("@opentelemetry/"), + ), +); + +/** Released together from opentelemetry-js: the experimental (0.x) line. */ +const EXPERIMENTAL = [ + "@opentelemetry/api-logs", + "@opentelemetry/exporter-logs-otlp-proto", + "@opentelemetry/exporter-metrics-otlp-proto", + "@opentelemetry/exporter-trace-otlp-proto", + "@opentelemetry/instrumentation", + "@opentelemetry/instrumentation-http", + "@opentelemetry/sdk-logs", + "@opentelemetry/sdk-node", +]; +/** Released together from opentelemetry-js: the stable SDK line. */ +const STABLE = [ + "@opentelemetry/context-async-hooks", + "@opentelemetry/core", + "@opentelemetry/resources", + "@opentelemetry/sdk-metrics", + "@opentelemetry/sdk-trace-base", +]; + +// WHY: OTel packages share global state through @opentelemetry/api. Two versions of one package +// in an app's tree register two providers, and spans or metrics from one silently never export. +describe("OpenTelemetry pins", () => { + it("are exact", () => { + for (const [name, version] of Object.entries(pins)) { + expect(version, name).toMatch(/^\d+\.\d+\.\d+$/); + } + }); + + it.each([ + ["experimental", EXPERIMENTAL], + ["stable", STABLE], + ])("share one version across the %s family", (_family, names) => { + for (const name of names) expect(pins[name], name).toBeDefined(); + expect(new Set(names.map((name) => pins[name])).size).toBe(1); + }); +}); diff --git a/src/__tests__/otlp-export-helpers.ts b/src/__tests__/otlp-export-helpers.ts new file mode 100644 index 0000000..9b920e5 --- /dev/null +++ b/src/__tests__/otlp-export-helpers.ts @@ -0,0 +1,49 @@ +import { vi } from "vitest"; +import { logs } from "@opentelemetry/api-logs"; +import type { ReadableLogRecord } from "@opentelemetry/sdk-logs"; +import { shutdownTelemetry, startTelemetry } from "../sdk.js"; +import type { TelemetryOptions } from "../options.js"; + +/** Records the real log pipeline handed to its (mocked) OTLP exporter. */ +export const exported: ReadableLogRecord[] = []; + +/** An OTLP log exporter that keeps records in `exported`; install with `vi.mock`. */ +export function memoryLogExporterModule() { + return { + OTLPLogExporter: class { + export(records: ReadableLogRecord[], done: (result: { code: number }) => void): void { + exported.push(...records); + done({ code: 0 }); + } + shutdown(): Promise { + return Promise.resolve(); + } + forceFlush(): Promise { + return Promise.resolve(); + } + }, + }; +} + +/** Boots the real SDK against an unreachable collector, with logs going to `exported`. */ +export async function bootWithMemoryLogs(options: Omit): Promise { + vi.stubEnv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://127.0.0.1:9"); + await startTelemetry({ + ...options, + esmLoaderHook: false, + env: { OTEL_EXPORTER_OTLP_ENDPOINT: "http://127.0.0.1:9", OTEL_LOG_LEVEL: "NONE" }, + }); +} + +/** Exports everything emitted so far: the sink emits on a microtask, the batch on flush. */ +export async function flushLogs(): Promise { + await Promise.resolve(); + await (logs.getLoggerProvider() as { forceFlush?: () => Promise }).forceFlush?.(); + return exported; +} + +export async function stopTelemetry(): Promise { + await shutdownTelemetry(1_000); + logs.disable(); + vi.unstubAllEnvs(); +} diff --git a/src/__tests__/profiling-wiring.test.ts b/src/__tests__/profiling-wiring.test.ts new file mode 100644 index 0000000..f312932 --- /dev/null +++ b/src/__tests__/profiling-wiring.test.ts @@ -0,0 +1,51 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const init = vi.fn(); +const start = vi.fn(); +const stop = vi.fn<() => Promise>(async () => undefined); + +vi.mock("@pyroscope/nodejs", () => ({ default: { init, start, stop } })); + +describe("profiling wiring in startTelemetry and shutdownTelemetry", () => { + beforeEach(() => { + vi.clearAllMocks(); + stop.mockImplementation(async () => undefined); + }); + afterEach(async () => { + const { shutdownTelemetry } = await import("../sdk.js"); + await shutdownTelemetry(100); + }); + + it("profiles with only a Pyroscope address, without enabling OTLP export", async () => { + const { startTelemetry } = await import("../sdk.js"); + const config = await startTelemetry({ + serviceName: "api", + env: { PYROSCOPE_SERVER_ADDRESS: "http://p:4040" }, + }); + expect(config.enabled).toBe(false); + expect(init).toHaveBeenCalledOnce(); + expect(start).toHaveBeenCalledOnce(); + }); + + it("stops the profiler on shutdown", async () => { + const { startTelemetry, shutdownTelemetry } = await import("../sdk.js"); + await startTelemetry({ + serviceName: "api", + env: { PYROSCOPE_SERVER_ADDRESS: "http://p:4040" }, + }); + await shutdownTelemetry(1_000); + expect(stop).toHaveBeenCalledOnce(); + }); + + it("does not let a profiler stop that never resolves stall shutdown", async () => { + stop.mockImplementation(() => new Promise(() => undefined)); + const { startTelemetry, shutdownTelemetry } = await import("../sdk.js"); + await startTelemetry({ + serviceName: "api", + env: { PYROSCOPE_SERVER_ADDRESS: "http://p:4040" }, + }); + const began = Date.now(); + await expect(shutdownTelemetry(200)).resolves.toBeUndefined(); + expect(Date.now() - began).toBeLessThan(1_000); + }); +}); diff --git a/src/__tests__/profiling.test.ts b/src/__tests__/profiling.test.ts new file mode 100644 index 0000000..a2759f7 --- /dev/null +++ b/src/__tests__/profiling.test.ts @@ -0,0 +1,94 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const init = vi.fn(); +const start = vi.fn(); +const stop = vi.fn(async () => undefined); +const loaded = vi.fn(); + +vi.mock("@pyroscope/nodejs", () => { + loaded(); + return { default: { init, start, stop } }; +}); + +const config = { + enabled: true, + serviceName: "api", + serviceVersion: "abc123", + serviceInstanceId: "replica-1", + environment: "cody-dev-env", +}; + +describe("startProfiling", () => { + beforeEach(async () => { + vi.clearAllMocks(); + await (await import("../profiling.js")).stopProfiling(); + }); + + it("returns false and loads nothing without an address", async () => { + const { startProfiling } = await import("../profiling.js"); + expect(await startProfiling(config, {})).toBe(false); + expect(loaded).not.toHaveBeenCalled(); + expect(init).not.toHaveBeenCalled(); + }); + + it("stays off under the kill switch even with an address", async () => { + const { startProfiling } = await import("../profiling.js"); + const env = { PYROSCOPE_SERVER_ADDRESS: "http://p:4040", OTEL_SDK_DISABLED: "true" }; + expect(await startProfiling(config, env)).toBe(false); + expect(init).not.toHaveBeenCalled(); + }); + + it("tags only env and version, so no request identifier reaches a profile", async () => { + const { startProfiling, stopProfiling } = await import("../profiling.js"); + expect(await startProfiling(config, { PYROSCOPE_SERVER_ADDRESS: "http://p:4040" })).toBe(true); + expect(init).toHaveBeenCalledWith({ + serverAddress: "http://p:4040", + appName: "api", + tags: { env: "cody-dev-env", version: "abc123" }, + // Literals, checked against @pyroscope/nodejs 0.6.4 dist/esm/profilers/pyroscope-profiler.js + // (the defaults are not exported): DEFAULT_FLUSH_DURATION_MS = 60000 (l.10), + // DEFAULT_SAMPLING_DURATION_MS = 1e3 * 60 (l.12), DEFAULT_SAMPLING_INTERVAL_MICROS = + // 1e6 / 100 Hz = 10000 (l.14), DEFAULT_SAMPLING_INTERVAL_BYTES = 1024 * 512 = 524288 (l.16), + // DEFAULT_STACK_DEPTH = 64 (l.17). + flushIntervalMs: 60_000, + wall: { samplingDurationMs: 60_000, samplingIntervalMicros: 10_000 }, + heap: { samplingIntervalBytes: 524_288, stackDepth: 64 }, + }); + expect(start).toHaveBeenCalledOnce(); + await stopProfiling(); + expect(stop).toHaveBeenCalledOnce(); + }); + + it("does not init twice on a second call", async () => { + const { startProfiling } = await import("../profiling.js"); + const env = { PYROSCOPE_SERVER_ADDRESS: "http://p:4040" }; + await startProfiling(config, env); + expect(await startProfiling(config, env)).toBe(true); + expect(init).toHaveBeenCalledOnce(); + }); + + it("logs profiling_start_failed when the profiler fails to start", async () => { + const { log } = await import("../log-safely.js"); + const warn = vi.spyOn(log, "warn").mockImplementation(() => undefined); + start.mockImplementationOnce(() => { + throw new Error("native binary missing"); + }); + const { startProfiling } = await import("../profiling.js"); + await startProfiling(config, { PYROSCOPE_SERVER_ADDRESS: "http://p:4040" }); + expect(warn).toHaveBeenCalledWith( + expect.objectContaining({ event: "profiling_start_failed" }), + "profiling_start_failed", + ); + warn.mockRestore(); + }); + + it("returns false instead of throwing when the profiler fails to start", async () => { + start.mockImplementationOnce(() => { + throw new Error("native binary missing"); + }); + const { startProfiling } = await import("../profiling.js"); + await expect( + startProfiling(config, { PYROSCOPE_SERVER_ADDRESS: "http://p:4040" }), + ).resolves.toBe(false); + }); +}); diff --git a/src/__tests__/redacting-exporter-paths.test.ts b/src/__tests__/redacting-exporter-paths.test.ts new file mode 100644 index 0000000..4de259a --- /dev/null +++ b/src/__tests__/redacting-exporter-paths.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it } from "vitest"; +import { SpanKind, type Attributes } from "@opentelemetry/api"; +import { + BasicTracerProvider, + InMemorySpanExporter, + SimpleSpanProcessor, +} from "@opentelemetry/sdk-trace-base"; +import { RedactingSpanExporter } from "../redacting-exporter.js"; + +const TOKEN = "3f2b8c1e-9a4d-4e6f-b1a2-7c8d9e0f1a2b"; + +function exportSpan(kind: SpanKind, attributes: Attributes, name = "GET") { + const memory = new InMemorySpanExporter(); + const provider = new BasicTracerProvider({ + spanProcessors: [new SimpleSpanProcessor(new RedactingSpanExporter(memory))], + }); + const span = provider.getTracer("t").startSpan(name, { kind, attributes }); + span.addEvent("retry", { "url.full": `https://api.test/v1/applications/by-token/${TOKEN}` }); + span.end(); + return memory.getFinishedSpans()[0]!; +} + +// WHY: a bearer token in a URL path survives query stripping. The applicant's intake token +// (`/apply/` on web, `/v1/applications/by-token/` on the api) opens and submits +// the application for anyone who reads it out of a trace. +describe("RedactingSpanExporter — token-bearing paths", () => { + it("replaces the path in every URL attribute with the server span's route template", () => { + const span = exportSpan(SpanKind.SERVER, { + "http.route": "/v1/applications/by-token/:token", + "url.full": `https://api.test/v1/applications/by-token/${TOKEN}?x=1`, + "url.original": `/v1/applications/by-token/${TOKEN}`, + "url.path": `/v1/applications/by-token/${TOKEN}`, + "http.target": `/v1/applications/by-token/${TOKEN}?x=1`, + "http.url": `http://api.test:4000/v1/applications/by-token/${TOKEN}`, + }); + + expect(JSON.stringify([span.attributes, span.events])).not.toContain(TOKEN); + expect(span.attributes).toMatchObject({ + "url.full": "https://api.test/v1/applications/by-token/:token", + "url.original": "/v1/applications/by-token/:token", + "url.path": "/v1/applications/by-token/:token", + "http.target": "/v1/applications/by-token/:token", + "http.url": "http://api.test:4000/v1/applications/by-token/:token", + }); + }); + + it("uses the web route template for a Next.js span", () => { + const span = exportSpan(SpanKind.SERVER, { + "http.route": "/apply/[token]/submitted", + "http.target": `/apply/${TOKEN}/submitted`, + }); + expect(span.attributes["http.target"]).toBe("/apply/[token]/submitted"); + }); + + it("templates a client span to a token route, which carries no http.route", () => { + const span = exportSpan(SpanKind.CLIENT, { + "url.full": `https://api.test/v1/applications/by-token/${TOKEN}/submit`, + "url.path": `/v1/applications/by-token/${TOKEN}/submit`, + }); + + expect(JSON.stringify(span.attributes)).not.toContain(TOKEN); + expect(span.attributes["url.full"]).toBe( + "https://api.test/v1/applications/by-token/:token/submit", + ); + expect(span.attributes["url.path"]).toBeUndefined(); + }); + + it("drops url.path from a client span, whose path no route template covers", () => { + const span = exportSpan(SpanKind.CLIENT, { + "url.full": "https://api.stripe.com/v1/customers/cus_123?expand=x", + "url.path": "/v1/customers/cus_123", + "network.peer.address": "10.0.0.7", + }); + expect(span.attributes["url.path"]).toBeUndefined(); + expect(span.attributes["network.peer.address"]).toBeUndefined(); + expect(span.attributes["url.full"]).toBe("https://api.stripe.com/v1/customers/cus_123"); + }); + + it("keeps no trace of a token in a server span's url.path once it has a route", () => { + const span = exportSpan(SpanKind.SERVER, { + "url.path": "/v1/applications/by-token/SECRET", + "http.route": "/v1/applications/by-token/:token", + }); + expect(JSON.stringify([span.name, span.attributes, span.events])).not.toContain("SECRET"); + expect(span.attributes["url.path"]).toBe("/v1/applications/by-token/:token"); + }); + + it("drops the peer address, which is the caller's IP on a server span", () => { + const span = exportSpan(SpanKind.SERVER, { + "http.route": "/v1/tenants", + "network.peer.address": "203.0.113.9", + "network.peer.port": 51234, + }); + expect(span.attributes["network.peer.address"]).toBeUndefined(); + expect(span.attributes["network.peer.port"]).toBe(51234); + }); + + it("templates a web page path on a span with no route", () => { + const span = exportSpan(SpanKind.SERVER, { "http.target": `/apply/${TOKEN}?search=Jane` }); + expect(span.attributes["http.target"]).toBe("/apply/[token]"); + }); + + it("drops an unmatched span's url.path and keeps url.full without its query", () => { + const span = exportSpan(SpanKind.SERVER, { + "http.route": "unmatched", + "url.full": "https://api.test/v1/leases/abc-123?search=Jane", + "url.path": "/v1/leases/abc-123", + }); + expect(span.attributes["url.full"]).toBe("https://api.test/v1/leases/abc-123"); + expect(span.attributes["url.path"]).toBeUndefined(); + }); + + it("leaves a path that only mentions the word apply elsewhere", () => { + const span = exportSpan(SpanKind.CLIENT, { + "url.full": "https://x.test/v1/leases/abc/apply/credit", + }); + expect(span.attributes["url.full"]).toBe("https://x.test/v1/leases/abc/apply/credit"); + }); +}); + +// WHY: Next.js names its root span `${method} ${req.url}` and renames it only when a route +// resolves. An unrouted request (a 404 under /apply/) exports the raw URL, query +// included, as the span name and as `next.span_name`. +describe("RedactingSpanExporter — span names", () => { + it.each([ + ["GET /apply/tok_SECRET/unknown?x=1", "GET /apply/[token]/unknown"], + [ + "GET https://api.test/v1/applications/by-token/tok_SECRET?x=1", + "GET https://api.test/v1/applications/by-token/:token", + ], + ["GET /v1/foo?search=Jane%20Doe", "GET /v1/foo"], + ["POST https://user:pass@api.test/v1/x#frag", "POST https://api.test/v1/x"], + ])("scrubs %s in the name and next.span_name", (name, expected) => { + const span = exportSpan(SpanKind.SERVER, { "next.span_name": name }, name); + + expect(span.name).toBe(expected); + expect(span.attributes["next.span_name"]).toBe(expected); + expect(JSON.stringify([span.name, span.attributes])).not.toMatch(/tok_SECRET|Jane|pass/); + }); + + it("uses the route template for the name when the span has one", () => { + const span = exportSpan( + SpanKind.SERVER, + { "http.route": "/apply/[token]" }, + "GET /apply/tok_SECRET?x=1", + ); + expect(span.name).toBe("GET /apply/[token]"); + }); + + it.each([ + "GET", + "outbox.process x", + "GET /v1/applications/by-token/:token", + "render route (app) /apply/[token]", + ])("leaves %s unchanged", (name) => { + expect(exportSpan(SpanKind.INTERNAL, {}, name).name).toBe(name); + }); +}); diff --git a/src/__tests__/redacting-exporter.test.ts b/src/__tests__/redacting-exporter.test.ts new file mode 100644 index 0000000..867adeb --- /dev/null +++ b/src/__tests__/redacting-exporter.test.ts @@ -0,0 +1,287 @@ +import { describe, expect, it } from "vitest"; +import { SpanStatusCode } from "@opentelemetry/api"; +import type { ExportResult } from "@opentelemetry/core"; +import { + BasicTracerProvider, + InMemorySpanExporter, + SimpleSpanProcessor, + type ReadableSpan, +} from "@opentelemetry/sdk-trace-base"; +import { RedactingSpanExporter } from "../redacting-exporter.js"; +import { stripQuery } from "../redact.js"; + +function exportOne(attributes: Record) { + const memory = new InMemorySpanExporter(); + const provider = new BasicTracerProvider({ + spanProcessors: [new SimpleSpanProcessor(new RedactingSpanExporter(memory))], + }); + const span = provider.getTracer("t").startSpan("GET /v1/tenants", { attributes }); + span.addEvent("exception", { "url.full": "https://x/y?token=SECRET" }); + span.end(); + return memory.getFinishedSpans()[0]!; +} + +describe("RedactingSpanExporter", () => { + it("drops query strings, headers and redis statements", () => { + const span = exportOne({ + "url.full": "https://api.carboncrei.io/v1/tenants?search=Jane%20Doe#frag", + "url.query": "search=Jane%20Doe", + "url.path": "/v1/tenants/Jane%20Doe", + "http.request.header.authorization": "Bearer SECRET", + "db.system.name": "redis", + "db.query.text": 'hset bull:invoice-email:1 data {"email":"jane@example.com"}', + "http.route": "/v1/tenants", + }); + const serialized = JSON.stringify([span.attributes, span.events]); + expect(serialized).not.toMatch(/Jane|SECRET|jane@example\.com/); + expect(span.attributes["url.full"]).toBe("https://api.carboncrei.io/v1/tenants"); + expect(span.attributes["url.path"]).toBe("/v1/tenants"); + expect(span.attributes["http.route"]).toBe("/v1/tenants"); + expect(span.spanContext().traceId).toMatch(/^[0-9a-f]{32}$/); + }); + it("keeps parameterized SQL from Prisma", () => { + const span = exportOne({ + "db.system.name": "postgresql", + "db.query.text": "SELECT 1 WHERE id = $1", + }); + expect(span.attributes["db.query.text"]).toBe("SELECT 1 WHERE id = $1"); + }); + + it("scrubs links with url and header attributes", () => { + const memory = new InMemorySpanExporter(); + const provider = new BasicTracerProvider({ + spanProcessors: [new SimpleSpanProcessor(new RedactingSpanExporter(memory))], + }); + const span = provider.getTracer("t").startSpan("GET /v1/tenants", { + links: [ + { + context: provider.getTracer("t").startSpan("linked").spanContext(), + attributes: { + "url.full": "https://api.carboncrei.io/v1/tenants?search=Jane%20Doe", + "http.request.header.authorization": "Bearer SECRET", + }, + }, + ], + }); + span.end(); + const exported = memory.getFinishedSpans()[0]!; + const serialized = JSON.stringify(exported.links); + expect(serialized).not.toMatch(/Jane|SECRET/); + const linkAttrs = exported.links[0]?.attributes; + expect(linkAttrs?.["url.full"]).toBe("https://api.carboncrei.io/v1/tenants"); + expect(linkAttrs?.["http.request.header.authorization"]).toBeUndefined(); + }); + + it("scrubs exception.message and stacktrace with recorded exceptions", () => { + const memory = new InMemorySpanExporter(); + const provider = new BasicTracerProvider({ + spanProcessors: [new SimpleSpanProcessor(new RedactingSpanExporter(memory))], + }); + const span = provider.getTracer("t").startSpan("test"); + span.recordException(new Error("duplicate email jane@example.com")); + span.end(); + const exported = memory.getFinishedSpans()[0]!; + const serialized = JSON.stringify([exported.attributes, exported.events]); + expect(serialized).not.toMatch(/jane@example\.com/); + const exceptionEvent = exported.events.find((e) => e.name === "exception"); + expect(exceptionEvent?.attributes?.["exception.type"]).toBe("Error"); + expect(exceptionEvent?.attributes?.["exception.message"]).toBe("[redacted]"); + const stacktrace = exceptionEvent?.attributes?.["exception.stacktrace"] as string | undefined; + expect(stacktrace).toContain("Error: [redacted]"); + expect(stacktrace).toMatch(/\n\s+at /); + }); + + it("scrubs status.message but keeps status.code", () => { + const memory = new InMemorySpanExporter(); + const provider = new BasicTracerProvider({ + spanProcessors: [new SimpleSpanProcessor(new RedactingSpanExporter(memory))], + }); + const span = provider.getTracer("t").startSpan("test"); + span.setStatus({ code: SpanStatusCode.ERROR, message: "ssn 123-45-6789" }); + span.end(); + const exported = memory.getFinishedSpans()[0]!; + expect(exported.status.message).toBe("[redacted]"); + expect(exported.status.code).toBe(SpanStatusCode.ERROR); + }); + + it("drops db.query.parameter. and db.operation.parameter. prefixes", () => { + const span = exportOne({ + "db.query.parameter.id": "12345", + "db.operation.parameter.email": "jane@example.com", + "db.query.text": "SELECT 1", + }); + const attrs = span.attributes; + expect(attrs["db.query.parameter.id"]).toBeUndefined(); + expect(attrs["db.operation.parameter.email"]).toBeUndefined(); + expect(attrs["db.query.text"]).toBe("SELECT 1"); + }); + + it("drops enduser. and user. prefixes", () => { + const span = exportOne({ + "enduser.id": "tenant-123", + "enduser.email": "jane@example.com", + "user.agent": "chrome", + "user.ip.address": "192.168.1.1", + "http.route": "/api/test", + }); + const attrs = span.attributes; + expect(attrs["enduser.id"]).toBeUndefined(); + expect(attrs["enduser.email"]).toBeUndefined(); + expect(attrs["user.agent"]).toBeUndefined(); + expect(attrs["user.ip.address"]).toBeUndefined(); + expect(attrs["http.route"]).toBe("/api/test"); + }); + + it("drops client.address", () => { + const span = exportOne({ + "client.address": "192.168.1.1", + "http.route": "/api/test", + }); + const attrs = span.attributes; + expect(attrs["client.address"]).toBeUndefined(); + expect(attrs["http.route"]).toBe("/api/test"); + }); + + it("stripQuery removes userinfo from URLs", () => { + expect(stripQuery("https://user:pass@host/p?q")).toBe("https://host/p"); + expect(stripQuery("https://user:pass@host/p#frag")).toBe("https://host/p"); + expect(stripQuery("https://host/p")).toBe("https://host/p"); + expect(stripQuery("HTTPS://user:pass@host/p?q")).toBe("HTTPS://host/p"); + expect(stripQuery("Https://user:pass@host/p")).toBe("Https://host/p"); + }); + + // WHY: BullMQ's worker span carries the processor's return value and the thrown message, + // and Queue.updateJobProgress the progress payload, any of which can hold resident data. + it("redacts the BullMQ failure reason and drops job results and progress", () => { + const span = exportOne({ + "bullmq.job.failed.reason": "duplicate email jane@example.com", + "bullmq.job.result": '{"email":"jane@example.com"}', + "bullmq.job.progress": '{"tenant":"Jane Doe"}', + "bullmq.job.name": "invoice-email", + "bullmq.queue.name": "invoice-email", + }); + expect(JSON.stringify(span.attributes)).not.toMatch(/jane@example\.com|Jane/); + expect(span.attributes["bullmq.job.failed.reason"]).toBe("[redacted]"); + expect(span.attributes["bullmq.job.result"]).toBeUndefined(); + expect(span.attributes["bullmq.job.progress"]).toBeUndefined(); + expect(span.attributes["bullmq.job.name"]).toBe("invoice-email"); + expect(span.attributes["bullmq.queue.name"]).toBe("invoice-email"); + }); + + it("treats db.system.name: 'redis' + db.query.text as Redis", () => { + const span = exportOne({ + "db.system.name": "redis", + "db.query.text": 'hset bull:job data {"email":"jane@example.com"}', + }); + expect(span.attributes["db.query.text"]).toBeUndefined(); + }); + + it("treats db.system: 'redis' + db.statement as Redis", () => { + const span = exportOne({ + "db.system": "redis", + "db.statement": 'hset bull:job data {"email":"jane@example.com"}', + }); + const attrs = span.attributes; + expect(attrs["db.statement"]).toBeUndefined(); + }); + + it("proxies resource, parentSpanContext, instrumentationScope, startTime, endTime, duration, and spanContext unchanged", () => { + let rawSpan: ReadableSpan | undefined; + const memory = new InMemorySpanExporter(); + const captureRaw = { + export(spans: ReadableSpan[], done: (result: ExportResult) => void) { + rawSpan = spans[0]; + memory.export(spans, done); + }, + shutdown() { + return memory.shutdown(); + }, + forceFlush() { + return memory.forceFlush?.() ?? Promise.resolve(); + }, + }; + const provider = new BasicTracerProvider({ + spanProcessors: [ + new SimpleSpanProcessor(captureRaw), + new SimpleSpanProcessor(new RedactingSpanExporter(memory)), + ], + }); + const span = provider + .getTracer("t") + .startSpan("test", { attributes: { "exception.message": "leak" } }); + span.end(); + const exported = memory.getFinishedSpans()[0]!; + + expect(rawSpan).toBeDefined(); + const raw = rawSpan!; + expect(exported.resource).toBe(raw.resource); + expect(exported.parentSpanContext).toBe(raw.parentSpanContext); + expect(exported.instrumentationScope).toBe(raw.instrumentationScope); + expect(exported.startTime).toEqual(raw.startTime); + expect(exported.endTime).toEqual(raw.endTime); + expect(exported.duration).toEqual(raw.duration); + expect(exported.spanContext()).toEqual(raw.spanContext()); + }); + + it("forceFlush works with stub exporter having only export and shutdown", async () => { + const stub = { + export(_spans: ReadableSpan[], done: (result: ExportResult) => void) { + done({ code: 0 }); + }, + shutdown() { + return Promise.resolve(); + }, + }; + const redacting = new RedactingSpanExporter(stub); + const result = await redacting.forceFlush(); + expect(result).toBeUndefined(); + }); + + it("redacts multi-line exception messages with PII on non-first lines", () => { + const memory = new InMemorySpanExporter(); + const provider = new BasicTracerProvider({ + spanProcessors: [new SimpleSpanProcessor(new RedactingSpanExporter(memory))], + }); + const span = provider.getTracer("t").startSpan("test"); + span.recordException(new Error("first\nemail jane@x.com")); + span.end(); + const exported = memory.getFinishedSpans()[0]!; + const serialized = JSON.stringify(exported.events); + expect(serialized).not.toMatch(/jane@x\.com/); + const exceptionEvent = exported.events.find((e) => e.name === "exception"); + const stacktrace = exceptionEvent?.attributes?.["exception.stacktrace"] as string | undefined; + expect(stacktrace).toContain("Error: [redacted]"); + expect(stacktrace).toMatch(/\n\s+at /); + }); + + it("handles non-string exception.stacktrace by redacting it", () => { + const memory = new InMemorySpanExporter(); + const provider = new BasicTracerProvider({ + spanProcessors: [new SimpleSpanProcessor(new RedactingSpanExporter(memory))], + }); + const span = provider.getTracer("t").startSpan("test"); + span.addEvent("exception", { + "exception.stacktrace": 123, + "exception.type": "CustomError", + }); + span.end(); + const exported = memory.getFinishedSpans()[0]!; + const exceptionEvent = exported.events[0]!; + expect(exceptionEvent.attributes?.["exception.stacktrace"]).toBe("[redacted]"); + }); + + it("handles non-string exception.message (as number) by redacting it", () => { + const memory = new InMemorySpanExporter(); + const provider = new BasicTracerProvider({ + spanProcessors: [new SimpleSpanProcessor(new RedactingSpanExporter(memory))], + }); + const span = provider.getTracer("t").startSpan("test"); + span.addEvent("exception", { + "exception.message": 12345, + }); + span.end(); + const exported = memory.getFinishedSpans()[0]!; + const exceptionEvent = exported.events[0]!; + expect(exceptionEvent.attributes?.["exception.message"]).toBe("[redacted]"); + }); +}); diff --git a/src/__tests__/scrub-contract.test.ts b/src/__tests__/scrub-contract.test.ts new file mode 100644 index 0000000..8ff94bd --- /dev/null +++ b/src/__tests__/scrub-contract.test.ts @@ -0,0 +1,45 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { LOG_REDACT_PATTERNS } from "../log-redact.js"; +import { LOG_SCRUB_PATTERNS } from "../log-scrub.js"; +import { REDACT_KEY_SETS, REDACT_LITERALS, REDACT_PATTERNS } from "../redact.js"; +import { EXPORTER_LITERALS } from "../redacting-exporter.js"; +import { TOKEN_PATH_CONTRACT, URL_PATH_LITERALS, URL_PATH_PATTERNS } from "../url-path.js"; + +const CONTRACT_FILE = fileURLToPath(new URL("../scrub-contract.json", import.meta.url)); + +const UPDATE_HINT = + "This repo (codelittinc/otel-node) is the contract's source: update src/scrub-contract.json " + + "here, then open a PR in codelittinc/carbon-observability, the follower, updating its copy at " + + "agent/contracts/otel-node-scrub-contract.json and agent/config/scrub.alloy and " + + "agent/config/scrub-logs-metrics.alloy to match."; + +/** Every layer-1 pattern and key set the agent's layer-2 scrub repeats. */ +const LAYER_1: Record = { + ...LOG_REDACT_PATTERNS, + ...LOG_SCRUB_PATTERNS, + ...REDACT_PATTERNS, + ...URL_PATH_PATTERNS, + TOKEN_PATH_TEMPLATES: TOKEN_PATH_CONTRACT, + ...REDACT_KEY_SETS, + ...REDACT_LITERALS, + ...URL_PATH_LITERALS, + ...EXPORTER_LITERALS, +}; + +function readContract(): Record { + return JSON.parse(readFileSync(CONTRACT_FILE, "utf8")) as Record; +} + +describe("scrub contract", () => { + const contract = readContract(); + + it("pins exactly the layer-1 patterns and key sets", () => { + expect(Object.keys(contract).sort(), UPDATE_HINT).toEqual(Object.keys(LAYER_1).sort()); + }); + + it.each(Object.entries(LAYER_1))("pins %s", (name, value) => { + expect(contract[name], UPDATE_HINT).toEqual(value); + }); +}); diff --git a/src/__tests__/scrub-samples.ts b/src/__tests__/scrub-samples.ts new file mode 100644 index 0000000..448dce3 --- /dev/null +++ b/src/__tests__/scrub-samples.ts @@ -0,0 +1,86 @@ +// Shared by the scrub tests. + +/** Deterministic pseudo-random strings over the characters every pattern turns on. */ +export function* samples(count: number): Generator { + const pieces = [ + "a", + "Z", + "1", + "9", + ".", + "-", + "_", + "%", + "+", + "@", + ":", + "/", + "//", + "://", + "?", + "#", + " ", + "\n", + "\t", + "at ", + "\n at ", + "com", + "x.io", + "http", + "https://", + "123-45-6789", + "12345678", + "jane@example.com", + "=", + "é", + "\r", + "\r\n at ", + "\u00a0", + "\u2028", + "\u3000", + "HTTP://", + "Https://h?q", + "u:p@", + "HTTPS://user:pw@h/p", + '"//', + "(//u:", + "p@s", + "p?s@", + ":8080/", + ":p/s@", + ",", + '"', + "'", + "<", + "`", + "[::1]", + "]", + ";", + ",//", + "u:ab://", + "#c@", + ":/", + " jdbc:pg://", + "://a:b@c@d/", + "'s@", + '"x@', + '\\"', + "\\/\\/", + "\\", + ":2024#", + "=", + " https:/", + "1pg://", + "h:/", + ";j=S", + ":\\a/", + "')", + "''", + ]; + let seed = 42; + const next = () => (seed = (seed * 1103515245 + 12345) % 2 ** 31); + for (let i = 0; i < count; i++) { + const length = 1 + (next() % 14); + yield Array.from({ length }, () => pieces[next() % pieces.length]).join(""); + } +} diff --git a/src/__tests__/sdk-boot-shutdown.test.ts b/src/__tests__/sdk-boot-shutdown.test.ts new file mode 100644 index 0000000..bba2a38 --- /dev/null +++ b/src/__tests__/sdk-boot-shutdown.test.ts @@ -0,0 +1,65 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { logs } from "@opentelemetry/api-logs"; +import { LoggerProvider, SimpleLogRecordProcessor } from "@opentelemetry/sdk-logs"; + +const state = vi.hoisted(() => ({ + provider: undefined as LoggerProvider | undefined, + /** Bodies of the records exported; kept after shutdown, unlike the in-memory exporter's. */ + exported: [] as unknown[], +})); + +// A NodeSDK whose logger provider exports to memory, and whose shutdown closes that provider at +// once, as the real one does. +vi.mock("@opentelemetry/sdk-node", () => ({ + NodeSDK: class { + start() { + const exporter = { + export(records: { body?: unknown }[], done: (result: { code: number }) => void) { + state.exported.push(...records.map((record) => record.body)); + done({ code: 0 }); + }, + shutdown: () => Promise.resolve(), + forceFlush: () => Promise.resolve(), + }; + state.provider = new LoggerProvider({ + processors: [new SimpleLogRecordProcessor({ exporter })], + }); + logs.setGlobalLoggerProvider(state.provider); + } + shutdown(): Promise { + return state.provider!.shutdown(); + } + }, +})); + +import { bootSdk } from "../sdk-boot.js"; +import { forwardLogLine, setLogLineSink } from "../log-sink.js"; + +const config = { + enabled: true, + serviceName: "api", + serviceVersion: "test", + environment: "test", + serviceInstanceId: "instance-1", +}; + +describe("bootSdk shutdown", () => { + afterEach(() => { + setLogLineSink(undefined); + logs.disable(); + }); + + // WHY: the sink emits a record on a microtask after the line is written. A process that logs + // its last line and shuts down in the same tick must still export that line. + it("exports a line logged in the same tick as the shutdown", async () => { + const booted = await bootSdk(config, { + logScope: "backbone", + ignoredPaths: [], + esmLoaderHook: false, + exportLogs: true, + }); + forwardLogLine(JSON.stringify({ level: "info", event: "worker_stopping", msg: "stopping" })); + await booted.shutdown(); + expect(state.exported).toEqual(["stopping"]); + }); +}); diff --git a/src/__tests__/sdk-lazy.test.ts b/src/__tests__/sdk-lazy.test.ts new file mode 100644 index 0000000..8d850ef --- /dev/null +++ b/src/__tests__/sdk-lazy.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it, vi } from "vitest"; + +const loaded = vi.hoisted(() => ({ modules: [] as string[] })); + +vi.mock("@opentelemetry/sdk-node", () => { + loaded.modules.push("@opentelemetry/sdk-node"); + return { NodeSDK: class {} }; +}); +vi.mock("bullmq-otel", () => { + loaded.modules.push("bullmq-otel"); + return { BullMQOtel: class {} }; +}); + +// WHY: a process with no endpoint (local runs, CI, every environment until the stack is +// switched on) must not pay for loading the SDK, its exporters and instrumentations. +describe("@codelittinc/otel-node with no endpoint", () => { + it("loads neither the SDK nor the BullMQ adapter", async () => { + const telemetry = await import("../index.js"); + const { bullmqTelemetry } = await import("../bullmq.js"); + expect((await telemetry.startTelemetry({ serviceName: "api", env: {} })).enabled).toBe(false); + expect(bullmqTelemetry()).toBeUndefined(); + expect(loaded.modules).toEqual([]); + }); +}); diff --git a/src/__tests__/sdk-lifecycle.test.ts b/src/__tests__/sdk-lifecycle.test.ts new file mode 100644 index 0000000..ae2d0e9 --- /dev/null +++ b/src/__tests__/sdk-lifecycle.test.ts @@ -0,0 +1,275 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const state = vi.hoisted(() => ({ + constructed: 0, + config: undefined as Record | undefined, + shutdownThrows: false, + register: vi.fn(), + registerOptions: { data: { marker: "iitm" } }, + acknowledged: vi.fn((): Promise => Promise.resolve()), + order: [] as string[], + interval: undefined as NodeJS.Timeout | undefined, +})); + +vi.mock("node:module", () => ({ register: state.register })); +vi.mock("import-in-the-middle", () => ({ + createAddHookMessageChannel: () => ({ + registerOptions: state.registerOptions, + // Like the real one: a referenced interval guards the wait until the ack lands. + waitForAllMessagesAcknowledged: () => { + state.interval = setInterval(() => undefined, 1_000); + state.order.push("acknowledged"); + return state.acknowledged().then(() => clearInterval(state.interval)); + }, + }), +})); +vi.mock("@opentelemetry/sdk-node", () => { + return { + NodeSDK: class { + constructor(config: Record) { + state.constructed += 1; + state.config = config; + } + start() { + state.order.push("start"); + } + shutdown(): Promise { + if (state.shutdownThrows) throw new Error("sync shutdown failure"); + return Promise.resolve(); + } + }, + }; +}); + +import { diag } from "@opentelemetry/api"; +import { BatchLogRecordProcessor } from "@opentelemetry/sdk-logs"; +import { hasLogLineSink } from "../log-sink.js"; +import { ScopedLogRecordProcessor } from "../log-otlp.js"; +import { bullmqTelemetry } from "../bullmq.js"; +import { shutdownTelemetry, startTelemetry } from "../sdk.js"; +import { diagLogger, log as telemetryLog } from "../log-safely.js"; + +const enabled = { OTEL_EXPORTER_OTLP_ENDPOINT: "http://127.0.0.1:9" }; + +describe("telemetry lifecycle", () => { + beforeEach(() => { + state.constructed = 0; + state.shutdownThrows = false; + state.order.length = 0; + state.register.mockClear(); + }); + afterEach(async () => { + state.shutdownThrows = false; + await shutdownTelemetry(100); + }); + + it("hooks only instrumented modules and waits for the loader to acknowledge", async () => { + await startTelemetry({ serviceName: "api", env: enabled }); + expect(state.register).toHaveBeenCalledWith( + "import-in-the-middle/hook.mjs", + expect.any(String), + state.registerOptions, + ); + expect(state.order).toEqual(["start", "acknowledged"]); + }); + + it("names the replica on the resource, so Prometheus labels each series with its instance", async () => { + await startTelemetry({ + serviceName: "api", + env: { ...enabled, RAILWAY_REPLICA_ID: "replica-7", RAILWAY_GIT_COMMIT_SHA: "abc123" }, + }); + const resource = state.config?.resource as { attributes: Record }; + expect(resource.attributes).toEqual({ + "service.name": "api", + "service.version": "abc123", + "service.instance.id": "replica-7", + "deployment.environment.name": "unknown", + }); + }); + + // WHY: logs reach OTLP only through the scrubbing logger's own output, never through a + // logger instrumentation that could see a record before it is scrubbed. + it("exports logs through one scope-filtered batch processor fed by the scrubbing logger", async () => { + await startTelemetry({ serviceName: "api", env: enabled }); + const processors = state.config?.logRecordProcessors as unknown[]; + expect(processors).toHaveLength(1); + expect(processors[0]).toBeInstanceOf(ScopedLogRecordProcessor); + expect((processors[0] as unknown as { inner: unknown }).inner).toBeInstanceOf( + BatchLogRecordProcessor, + ); + expect(hasLogLineSink()).toBe(true); + const names = (state.config?.instrumentations as { instrumentationName: string }[]).map( + (i) => i.instrumentationName, + ); + expect(names.some((name) => /pino|winston|bunyan/.test(name))).toBe(false); + }); + + // WHY: Alloy refuses a whole OTLP request over its body limit, and the logger does not cap a + // line, so the batch is bounded by record count (see sdk-boot.ts for the arithmetic). + it("exports logs in batches of at most 64 records", async () => { + await startTelemetry({ serviceName: "api", env: enabled }); + const [scoped] = state.config?.logRecordProcessors as { inner: unknown }[]; + // Reads the processor's private limit: a rename in the SDK fails here, not in production. + expect((scoped?.inner as { _maxExportBatchSize: number })._maxExportBatchSize).toBe(64); + }); + + it("stops feeding the log exporter on shutdown", async () => { + await startTelemetry({ serviceName: "api", env: enabled }); + await shutdownTelemetry(100); + expect(hasLogLineSink()).toBe(false); + }); + + it("exports no logs when the host opts out", async () => { + await startTelemetry({ serviceName: "web", env: enabled, esmLoaderHook: false, logs: false }); + expect(state.config?.logRecordProcessors).toEqual([]); + expect(hasLogLineSink()).toBe(false); + }); + + it("creates no log pipeline while telemetry is off", async () => { + await startTelemetry({ serviceName: "api", env: {} }); + await startTelemetry({ + serviceName: "api", + env: { ...enabled, OTEL_SDK_DISABLED: "true" }, + }); + expect(state.constructed).toBe(0); + expect(hasLogLineSink()).toBe(false); + }); + + it("filters backbone.* metric labels through the allowlist view", async () => { + await startTelemetry({ + serviceName: "api", + env: enabled, + metricViews: { instrumentPrefix: "backbone.", allowedAttributes: ["queue", "outcome"] }, + }); + expect(state.config?.views).toEqual([ + expect.objectContaining({ instrumentName: "backbone.*" }), + ]); + }); + + // WHY: a Next.js server runs bundled CommonJS, and the hook's specifier cannot be resolved + // from a bundle chunk. A failed register would take the whole SDK down with it. + it("starts without the ESM loader hook when the host has no ESM to hook", async () => { + await startTelemetry({ serviceName: "web", env: enabled, esmLoaderHook: false }); + expect(state.register).not.toHaveBeenCalled(); + expect(state.constructed).toBe(1); + expect(state.order).toEqual(["start"]); + }); + + it("returns the same config and builds one SDK on a second enabled call", async () => { + const first = await startTelemetry({ serviceName: "api", env: enabled }); + const second = await startTelemetry({ serviceName: "api", env: enabled }); + expect(second).toBe(first); + expect(state.constructed).toBe(1); + }); + + it("starts telemetry after an earlier disabled call", async () => { + expect((await startTelemetry({ serviceName: "api", env: {} })).enabled).toBe(false); + expect(state.constructed).toBe(0); + expect((await startTelemetry({ serviceName: "api", env: enabled })).enabled).toBe(true); + expect(state.constructed).toBe(1); + }); + + it("resolves the config when the loader hook fails", async () => { + state.register.mockImplementationOnce(() => { + throw new Error("hook failure"); + }); + const warn = vi.spyOn(telemetryLog, "warn").mockImplementation(() => undefined); + await expect(startTelemetry({ serviceName: "api", env: enabled })).resolves.toMatchObject({ + enabled: true, + }); + expect(warn).toHaveBeenCalledWith( + expect.objectContaining({ event: "telemetry_start_failed" }), + "telemetry_start_failed", + ); + warn.mockRestore(); + }); + + it("hands every queue the one BullMQ adapter built at start", async () => { + await startTelemetry({ serviceName: "api", env: enabled }); + const adapter = bullmqTelemetry(); + expect(adapter?.tracer).toBeDefined(); + expect(bullmqTelemetry()).toBe(adapter); + }); + + // WHY: a typo'd endpoint or a rejected export is otherwise silent; the started line names the + // collector host so an operator can tell telemetry is on, without leaking credentials. + it("logs one started line with the endpoint host only, and SDK warnings", async () => { + const info = vi.spyOn(telemetryLog, "info").mockImplementation(() => undefined); + const setLogger = vi.spyOn(diag, "setLogger"); + await startTelemetry({ + serviceName: "worker", + env: { OTEL_EXPORTER_OTLP_ENDPOINT: "https://user:pw@alloy.internal:4318/otlp?key=k" }, + }); + expect(info.mock.calls).toEqual([ + [ + { event: "telemetry_started", serviceName: "worker", endpointHost: "alloy.internal:4318" }, + "telemetry_started", + ], + ]); + expect(setLogger).toHaveBeenCalledWith(diagLogger, expect.objectContaining({ logLevel: 50 })); + info.mockRestore(); + setLogger.mockRestore(); + }); + + // NodeSDK installs its own logger when OTEL_LOG_LEVEL is set; a second one makes it warn that + // the logger is overwritten on every boot. + it("leaves the diag logger to NodeSDK when OTEL_LOG_LEVEL is set", async () => { + const setLogger = vi.spyOn(diag, "setLogger"); + vi.spyOn(telemetryLog, "info").mockImplementation(() => undefined); + await startTelemetry({ serviceName: "api", env: { ...enabled, OTEL_LOG_LEVEL: "debug" } }); + expect(setLogger).not.toHaveBeenCalled(); + vi.restoreAllMocks(); + }); + + it("still resolves when the logger itself throws while reporting a failed start", async () => { + state.register.mockImplementationOnce(() => { + throw new Error("hook failure"); + }); + const warn = vi.spyOn(telemetryLog, "warn").mockImplementation(() => { + throw new Error("logger broken"); + }); + await expect(startTelemetry({ serviceName: "api", env: enabled })).resolves.toMatchObject({ + enabled: true, + }); + warn.mockRestore(); + }); + + it("abandons a loader acknowledgement that never arrives and keeps telemetry running", async () => { + vi.useFakeTimers(); + try { + state.acknowledged.mockImplementationOnce(() => new Promise(() => undefined)); + const warn = vi.spyOn(telemetryLog, "warn").mockImplementation(() => undefined); + const pending = startTelemetry({ serviceName: "api", env: enabled }); + await vi.advanceTimersByTimeAsync(5_000); + await expect(pending).resolves.toMatchObject({ enabled: true }); + expect(warn).toHaveBeenCalledWith( + expect.objectContaining({ event: "telemetry_hook_ack_timeout" }), + "telemetry_hook_ack_timeout", + ); + expect(warn).not.toHaveBeenCalledWith( + expect.objectContaining({ event: "telemetry_start_failed" }), + expect.anything(), + ); + // The abandoned wait's interval must not hold the process open. + expect(state.interval?.hasRef()).toBe(false); + clearInterval(state.interval); + warn.mockRestore(); + } finally { + vi.useRealTimers(); + } + }); + + it("never rejects when the SDK shutdown throws synchronously", async () => { + await startTelemetry({ serviceName: "api", env: enabled }); + state.shutdownThrows = true; + await expect(shutdownTelemetry(100)).resolves.toBeUndefined(); + }); + + // WHY: options that cannot be read on a later call must not report export off while it runs. + it("returns the running config for unreadable options on a later call", async () => { + const first = await startTelemetry({ serviceName: "api", env: enabled }); + const warn = vi.spyOn(telemetryLog, "warn").mockImplementation(() => undefined); + await expect(startTelemetry(null as never)).resolves.toBe(first); + warn.mockRestore(); + }); +}); diff --git a/src/__tests__/sdk.test.ts b/src/__tests__/sdk.test.ts new file mode 100644 index 0000000..ea1d463 --- /dev/null +++ b/src/__tests__/sdk.test.ts @@ -0,0 +1,78 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { IORedisInstrumentation } from "@opentelemetry/instrumentation-ioredis"; +import { PgInstrumentation } from "@opentelemetry/instrumentation-pg"; +import { HttpInstrumentation } from "@opentelemetry/instrumentation-http"; +import type { IncomingMessage } from "node:http"; +import { trace } from "@opentelemetry/api"; +import { buildInstrumentations } from "../instrumentations.js"; +import { shutdownTelemetry, startTelemetry } from "../sdk.js"; + +describe("buildInstrumentations", () => { + it("traces redis only under a parent span, so BullMQ polling is silent", () => { + const redis = buildInstrumentations([]).find((i) => i instanceof IORedisInstrumentation)!; + expect(redis.getConfig()).toMatchObject({ requireParentSpan: true }); + }); + it("never records bound SQL values", () => { + const pg = buildInstrumentations([]).find((i) => i instanceof PgInstrumentation)!; + // packages/db's outbox-backlog sentinel test runs pg with these same options. + expect(pg.getConfig()).toMatchObject({ + enhancedDatabaseReporting: false, + requireParentSpan: true, + }); + }); + + // WHY: health probes and the PostHog proxy would each open a server span per request. An + // entry ignores its own path and everything under it, never a sibling that shares a prefix. + it.each([ + ["/health", true], + ["/ready?probe=1", true], + ["/health/deep", true], + ["/api/ready", true], + ["/ingest/e/?compression=gzip-js", true], + ["/ingest", true], + ["/readyz", false], + ["/ingestion", false], + ["/v1/tenants", false], + ])("ignoring %s: %s", (url, ignored) => { + const http = buildInstrumentations(["/health", "/ready", "/api/ready", "/ingest"]).find( + (i) => i instanceof HttpInstrumentation, + )!; + const hook = http.getConfig().ignoreIncomingRequestHook!; + expect(hook({ url } as IncomingMessage)).toBe(ignored); + }); +}); + +describe("startTelemetry", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("is a no-op without an endpoint", async () => { + expect((await startTelemetry({ serviceName: "api", env: {} })).enabled).toBe(false); + }); + + it("survives an unreachable collector without an unhandled rejection", async () => { + // The exporters read process.env themselves, so the endpoint must be stubbed there. + vi.stubEnv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://127.0.0.1:9"); + vi.stubEnv("OTEL_EXPORTER_OTLP_TIMEOUT", "500"); + const onRejection = vi.fn(); + process.on("unhandledRejection", onRejection); + try { + const config = await startTelemetry({ + serviceName: "api", + env: { OTEL_EXPORTER_OTLP_ENDPOINT: "http://127.0.0.1:9", OTEL_LOG_LEVEL: "NONE" }, + }); + expect(config.enabled).toBe(true); + trace.getTracer("t").startSpan("probe").end(); + const started = Date.now(); + await expect(shutdownTelemetry(2_000)).resolves.toBeUndefined(); + expect(Date.now() - started).toBeLessThan(3_000); + // Let any late rejection from the exporter surface before the listener is removed. + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setTimeout(resolve, 50)); + } finally { + process.off("unhandledRejection", onRejection); + } + expect(onRejection).not.toHaveBeenCalled(); + }, 10_000); +}); diff --git a/src/__tests__/serialization-hooks.test.ts b/src/__tests__/serialization-hooks.test.ts new file mode 100644 index 0000000..05924df --- /dev/null +++ b/src/__tests__/serialization-hooks.test.ts @@ -0,0 +1,146 @@ +import { inspect } from "node:util"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { captureConsole, releaseConsole } from "../console-capture.js"; +import { createTestLogger } from "../testing.js"; +import { bootWithMemoryLogs, exported, flushLogs, stopTelemetry } from "./otlp-export-helpers.js"; + +vi.mock("@opentelemetry/exporter-logs-otlp-proto", async () => + (await import("./otlp-export-helpers.js")).memoryLogExporterModule(), +); + +const LEAK = /hunter2|4111111111111111|123-45-6789/; +const TEXT = "hunter2 4111111111111111 123-45-6789"; +const payload = () => ({ + password: "hunter2", + accountNumber: "4111111111111111", + note: "ssn 123-45-6789", +}); + +class LeakyDate extends Date { + override toJSON(): string { + return payload() as unknown as string; + } + override toISOString(): string { + return TEXT; + } +} + +/** Values whose own serialization hook emits PII the sanitizer never walked. */ +const HOOKS: [string, () => unknown][] = [ + ["toJSON on an object", () => ({ toJSON: payload })], + ["toJSON on a function", () => Object.assign(() => undefined, { toJSON: payload })], + ["toJSON on a Map", () => Object.assign(new Map(), { toJSON: payload })], + ["toJSON on a Set", () => Object.assign(new Set(), { toJSON: payload })], + ["toJSON and toISOString on a Date subclass", () => new LeakyDate(0)], + ["toJSON on a RegExp", () => Object.assign(/x/, { toJSON: payload })], + ["toJSON on a byte view", () => Object.assign(new Uint8Array(2), { toJSON: payload })], + ["toJSON returning a value with its own toJSON", () => ({ toJSON: () => ({ toJSON: payload }) })], + ["own properties on a Map", () => Object.assign(new Map(), payload())], + ["own properties on a function", () => Object.assign(() => undefined, payload())], + ["inspect.custom", () => ({ [inspect.custom]: () => TEXT })], + ["toString", () => ({ toString: () => TEXT })], + ["Symbol.toPrimitive", () => ({ [Symbol.toPrimitive]: () => TEXT })], + [ + "getters", + () => ({ + get password() { + return "hunter2"; + }, + get accountNumber() { + return "4111111111111111"; + }, + get note() { + return "ssn 123-45-6789"; + }, + }), + ], +]; + +const POSITIONS: [string, (value: unknown) => unknown][] = [ + ["top level", (value) => value], + ["nested", (value) => ({ outer: { value } })], + ["inside an array", (value) => [1, value]], + ["inside a Map", (value) => new Map([["k", value]])], + ["inside a Set", (value) => new Set([value])], + ["inside a toJSON result", (value) => ({ toJSON: () => ({ value }) })], +]; + +const CASES = HOOKS.flatMap(([hook, make]) => + POSITIONS.map(([position, place]) => [hook, position, () => place(make())] as const), +); + +const original = { warn: console.warn, error: console.error }; + +beforeAll(async () => { + console.warn = () => undefined; + console.error = () => undefined; + await bootWithMemoryLogs({ serviceName: "acp-api" }); + captureConsole(); + await flushLogs(); +}); + +afterAll(async () => { + releaseConsole(); + console.warn = original.warn; + console.error = original.error; + await stopTelemetry(); +}); + +beforeEach(async () => { + await flushLogs(); + exported.length = 0; +}); + +// WHY: a value's own serialization hook runs after the sanitizer unless the sanitizer's output is +// inert; whatever the hook emits would be exported unredacted. +describe("serialization hooks", () => { + it.each(CASES)("log: %s, %s", (_hook, _position, value) => { + const { logger, records } = createTestLogger(); + logger.error({ field: value() }, "as a field"); + logger.error("as format args %s %o %j", value(), value() as object, value() as object); + const placed = value(); + if (typeof placed === "object" && placed !== null) logger.error(placed, "as the object"); + expect(records.length).toBeGreaterThanOrEqual(2); + expect(JSON.stringify(records)).not.toMatch(LEAK); + expect(JSON.stringify(records)).not.toContain("log_redaction_failed"); + }); + + it.each(CASES)("console capture: %s, %s", async (_hook, _position, value) => { + console.error("as an argument", value()); + console.error("as format args %s %o %O %j", value(), value(), value(), value()); + const records = await flushLogs(); + expect(records).toHaveLength(2); + expect(JSON.stringify(records.map((r) => [r.body, r.attributes]))).not.toMatch(LEAK); + expect(JSON.stringify(records.map((r) => [r.body, r.attributes]))).not.toContain( + "log_redaction_failed", + ); + }); + + // An error is serialized deny-by-default; its message and Prisma's meta target are the fields + // that are copied from the caller's value. + it.each(HOOKS)("log: %s as an error's message or Prisma meta target", (_hook, make) => { + const { logger, records } = createTestLogger(); + const withMessage = Object.assign(new Error("x"), { message: make() }); + const prisma = Object.assign(new Error("unique"), { + name: "PrismaClientKnownRequestError", + code: "P2002", + meta: { target: ["email", make()] }, + }); + logger.error({ err: withMessage }); + logger.error(withMessage); + logger.error({ err: prisma }, "prisma"); + expect(JSON.stringify(records)).not.toMatch(LEAK); + expect(JSON.stringify(records)).not.toContain("log_redaction_failed"); + }); + + it.each(HOOKS)("console capture: %s as an error's message", async (_hook, make) => { + console.error(Object.assign(new Error("x"), { message: make() })); + console.error("second", Object.assign(new Error("y"), { message: make() })); + const records = await flushLogs(); + expect(records).toHaveLength(2); + expect(JSON.stringify(records.map((r) => [r.body, r.attributes]))).not.toMatch(LEAK); + expect(JSON.stringify(records.map((r) => [r.body, r.attributes]))).not.toContain( + "log_redaction_failed", + ); + }); +}); diff --git a/src/__tests__/shared-log.test.ts b/src/__tests__/shared-log.test.ts new file mode 100644 index 0000000..fc821d5 --- /dev/null +++ b/src/__tests__/shared-log.test.ts @@ -0,0 +1,129 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { symbols } from "pino"; +import type { Logger } from "pino"; +import type * as SharedLogModule from "../shared-log.js"; + +type SharedLog = typeof SharedLogModule; + +/** A fresh copy of the module, so each case starts with an unconfigured process. */ +async function freshSharedLog(): Promise { + vi.resetModules(); + return import("../shared-log.js"); +} + +/** Points the shared logger at an in-memory stream; children created earlier write there too. */ +function capture(log: Logger): Record[] { + const records: Record[] = []; + (log as unknown as Record)[symbols.streamSym] = { + write: (line: string) => void records.push(JSON.parse(line) as Record), + }; + return records; +} + +describe("the shared logger", () => { + let mod: SharedLog; + let records: Record[]; + + beforeEach(async () => { + mod = await freshSharedLog(); + records = capture(mod.log); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("falls back to OTEL_SERVICE_NAME, then to unknown, until the process names itself", () => { + vi.stubEnv("OTEL_SERVICE_NAME", "from-otel"); + mod.log.info("a"); + vi.stubEnv("OTEL_SERVICE_NAME", ""); + mod.log.info("b"); + expect(records.map((r) => r.app)).toEqual(["from-otel", "unknown"]); + }); + + // Module-scope children are created at import, before the entry point calls configureLogger. + it("stamps the configured app on a child created before configuration", () => { + const early = mod.log.child({ component: "early" }); + mod.configureLogger({ service: "worker" }); + early.info("after"); + expect(records[0]).toMatchObject({ app: "worker", component: "early" }); + }); + + it("gives a package line both its component and the process app", () => { + mod.configureLogger({ service: "api" }); + mod.log.child({ component: "db" }).warn({ tenantId: "t-1" }, "slow query"); + expect(records[0]).toMatchObject({ + app: "api", + component: "db", + tenantId: "t-1", + msg: "slow query", + }); + }); + + it("keeps redacting child bindings", () => { + mod.log.child({ component: "db", applicantEmail: "a@b.co" }).info("m"); + expect(JSON.stringify(records[0])).not.toContain("a@b.co"); + }); + + // An imported module must not be able to rename the process after its entry point did. + it("keeps the first service name and warns when a later call names another", () => { + mod.configureLogger({ service: "api" }); + mod.configureLogger({ service: "api" }); + expect(records).toHaveLength(0); + + mod.configureLogger({ service: "mcp" }); + mod.log.info("x"); + expect(records[0]).toMatchObject({ + level: "warn", + event: "logger_reconfigure_ignored", + requested: "mcp", + app: "api", + }); + expect(records[1]).toMatchObject({ app: "api", msg: "x" }); + }); +}); + +describe("an invalid LOG_LEVEL", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("is reported when the process names itself, carrying the app", async () => { + vi.stubEnv("LOG_LEVEL", "verbose"); + const mod = await freshSharedLog(); + const records = capture(mod.log); + expect(records).toHaveLength(0); + mod.configureLogger({ service: "mcp" }); + expect(records).toEqual([ + expect.objectContaining({ event: "invalid_log_level", requested: "verbose", app: "mcp" }), + ]); + }); +}); + +describe("flushLog", () => { + it("resolves when flushing throws", async () => { + const mod = await freshSharedLog(); + const flush = vi.spyOn(mod.log, "flush").mockImplementation(() => { + throw new Error("stream gone"); + }); + try { + await expect(mod.flushLog(50)).resolves.toBeUndefined(); + } finally { + flush.mockRestore(); + } + }); + + it("resolves after the timeout when the flush never calls back", async () => { + const mod = await freshSharedLog(); + const flush = vi.spyOn(mod.log, "flush").mockImplementation(() => undefined); + vi.useFakeTimers(); + try { + const done = mod.flushLog(1_000); + await vi.advanceTimersByTimeAsync(1_000); + await expect(done).resolves.toBeUndefined(); + } finally { + vi.useRealTimers(); + flush.mockRestore(); + } + }); +}); diff --git a/src/__tests__/trace-context.test.ts b/src/__tests__/trace-context.test.ts new file mode 100644 index 0000000..4fa7b7a --- /dev/null +++ b/src/__tests__/trace-context.test.ts @@ -0,0 +1,151 @@ +import { afterAll, beforeEach, describe, expect, it } from "vitest"; +import { context, SpanStatusCode, trace } from "@opentelemetry/api"; +import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks"; +import { + BasicTracerProvider, + InMemorySpanExporter, + SimpleSpanProcessor, +} from "@opentelemetry/sdk-trace-base"; +import { suppressTracing } from "@opentelemetry/core"; +import { + currentTraceparent, + linkFromTraceparent, + withLinkedSpan, + withRootSpan, +} from "../trace-context.js"; + +const memory = new InMemorySpanExporter(); +const provider = new BasicTracerProvider({ spanProcessors: [new SimpleSpanProcessor(memory)] }); +context.setGlobalContextManager(new AsyncLocalStorageContextManager().enable()); +trace.setGlobalTracerProvider(provider); +const tracer = provider.getTracer("trace-context-test"); + +const STORED = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"; + +beforeEach(() => memory.reset()); +afterAll(async () => { + await provider.shutdown(); + trace.disable(); + context.disable(); +}); + +describe("currentTraceparent", () => { + it("is undefined outside any span", () => { + expect(currentTraceparent()).toBeUndefined(); + }); + + it("returns the W3C header of the active span", () => { + tracer.startActiveSpan("writer", (span) => { + const { traceId, spanId } = span.spanContext(); + expect(currentTraceparent()).toBe(`00-${traceId}-${spanId}-01`); + span.end(); + }); + }); +}); + +describe("linkFromTraceparent", () => { + it("links to the stored trace and span", () => { + expect(linkFromTraceparent(STORED)?.context).toMatchObject({ + traceId: "4bf92f3577b34da6a3ce929d0e0e4736", + spanId: "00f067aa0ba902b7", + }); + }); + + it.each([null, undefined, "", "garbage", "00-zz-00f067aa0ba902b7-01", `${STORED}-extra`])( + "gives no link for %j", + (value) => { + expect(linkFromTraceparent(value)).toBeUndefined(); + }, + ); + + it("gives no link for an all-zero trace ID", () => { + expect( + linkFromTraceparent("00-00000000000000000000000000000000-00f067aa0ba902b7-01"), + ).toBeUndefined(); + }); +}); + +describe("withLinkedSpan", () => { + it("runs the function inside a span linked to the stored trace", async () => { + const result = await withLinkedSpan("outbox.process X", STORED, async () => { + expect(trace.getActiveSpan()).toBeDefined(); + return 42; + }); + expect(result).toBe(42); + const [span] = memory.getFinishedSpans(); + expect(span!.name).toBe("outbox.process X"); + expect(span!.links[0]!.context.traceId).toBe("4bf92f3577b34da6a3ce929d0e0e4736"); + }); + + it("has no link for a malformed value and still runs the function", async () => { + await expect(withLinkedSpan("outbox.process X", "not-a-header", async () => 1)).resolves.toBe( + 1, + ); + expect(memory.getFinishedSpans()[0]!.links).toEqual([]); + }); + + it("marks the span as an error and rethrows", async () => { + await expect( + withLinkedSpan("outbox.process X", STORED, async () => { + throw new Error("handler failed"); + }), + ).rejects.toThrow("handler failed"); + const [span] = memory.getFinishedSpans(); + expect(span!.status.code).toBe(2); + expect(span!.ended).toBe(true); + }); +}); + +// The HTTP instrumentation runs an ignored path (`/ready`) with tracing suppressed. A check that +// fails there still needs a trace for its log lines to point at. +describe("withRootSpan", () => { + const suppressed = (fn: () => Promise) => + context.with(suppressTracing(context.active()), fn); + + it("records a span, active for the function, even where tracing is suppressed", async () => { + const traceIds: (string | undefined)[] = []; + await suppressed(() => + withRootSpan("readiness.check", async () => { + traceIds.push(trace.getActiveSpan()?.spanContext().traceId); + return 200; + }), + ); + const [span] = memory.getFinishedSpans(); + expect(span!.name).toBe("readiness.check"); + expect(traceIds).toEqual([span!.spanContext().traceId]); + expect(span!.parentSpanContext).toBeUndefined(); + }); + + it("ends with OK when the result is not a failure", async () => { + await expect( + withRootSpan( + "readiness.check", + async () => 200, + (s) => s >= 500, + ), + ).resolves.toBe(200); + expect(memory.getFinishedSpans()[0]!.status.code).toBe(SpanStatusCode.OK); + }); + + it("ends with ERROR when the result is a failure", async () => { + await expect( + withRootSpan( + "readiness.check", + async () => 503, + (s) => s >= 500, + ), + ).resolves.toBe(503); + expect(memory.getFinishedSpans()[0]!.status.code).toBe(SpanStatusCode.ERROR); + }); + + it("ends with ERROR and rethrows when the function throws", async () => { + await expect( + withRootSpan("readiness.check", async () => { + throw new Error("check failed"); + }), + ).rejects.toThrow("check failed"); + const [span] = memory.getFinishedSpans(); + expect(span!.status.code).toBe(SpanStatusCode.ERROR); + expect(span!.ended).toBe(true); + }); +}); diff --git a/src/__tests__/types-without-bullmq.test.ts b/src/__tests__/types-without-bullmq.test.ts new file mode 100644 index 0000000..cfc4b17 --- /dev/null +++ b/src/__tests__/types-without-bullmq.test.ts @@ -0,0 +1,48 @@ +import { fileURLToPath } from "node:url"; +import ts from "typescript"; +import { describe, expect, it } from "vitest"; + +const ROOT = fileURLToPath(new URL("../../", import.meta.url)); + +/** Type errors in `source`, compiled as an app's file with `bullmq` absent and lib checks on. */ +function typeErrors(source: string): string[] { + const file = `${ROOT}consumer-check.ts`; + const options: ts.CompilerOptions = { + strict: true, + noEmit: true, + skipLibCheck: false, + module: ts.ModuleKind.NodeNext, + moduleResolution: ts.ModuleResolutionKind.NodeNext, + target: ts.ScriptTarget.ES2022, + types: ["node"], + }; + const host = ts.createCompilerHost(options); + const hidden = (path: string) => /[\\/]node_modules[\\/](\.pnpm[\\/]bullmq@|bullmq[\\/])/.test(path); + const { fileExists, readFile, getSourceFile } = host; + host.fileExists = (path) => path === file || (!hidden(path) && fileExists.call(host, path)); + host.readFile = (path) => (path === file ? source : hidden(path) ? undefined : readFile.call(host, path)); + host.getSourceFile = (path, ...rest) => + path === file + ? ts.createSourceFile(path, source, ts.ScriptTarget.ES2022, true) + : getSourceFile.call(host, path, ...rest); + const program = ts.createProgram([file], options, host); + return ts + .getPreEmitDiagnostics(program) + .map((d) => ts.flattenDiagnosticMessageText(d.messageText, "\n")); +} + +// WHY: bullmq is an optional peer. An app without it must typecheck the main entry even with +// skipLibCheck off; only the `./bullmq` entry may name its types. +describe("the built types without bullmq installed", () => { + it("typecheck the main entry", () => { + const errors = typeErrors( + 'import * as otel from "./dist/index.js";\nexport const start = otel.startTelemetry;\n', + ); + expect(errors).toEqual([]); + }); + + it("fail the ./bullmq entry, so bullmq is really hidden", () => { + const errors = typeErrors('export * from "./dist/bullmq.js";\n'); + expect(errors.join("\n")).toMatch(/bullmq/); + }); +}); diff --git a/src/__tests__/unreachable-endpoint.test.ts b/src/__tests__/unreachable-endpoint.test.ts new file mode 100644 index 0000000..d8695c1 --- /dev/null +++ b/src/__tests__/unreachable-endpoint.test.ts @@ -0,0 +1,72 @@ +import { createServer, request, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { afterAll, describe, expect, it, vi } from "vitest"; +import { trace } from "@opentelemetry/api"; +import { log } from "../shared-log.js"; +import { isTelemetryStarted, shutdownTelemetry, startTelemetry } from "../sdk.js"; + +const UNREACHABLE = "http://127.0.0.1:9"; + +function serve(): Promise { + const server = createServer((_req, res) => { + trace.getTracer("probe").startActiveSpan("handler", (span) => { + log.info({ event: "request_handled" }, "handled"); + span.end(); + res.end("ok"); + }); + }); + return new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve(server))); +} + +function timedRequest(server: Server): Promise { + const { port } = server.address() as AddressInfo; + const started = performance.now(); + return new Promise((resolve, reject) => { + request({ host: "127.0.0.1", port, agent: false }, (res) => { + res.resume(); + res.on("end", () => resolve(performance.now() - started)); + }) + .on("error", reject) + .end(); + }); +} + +async function slowest(server: Server, count: number): Promise { + let worst = 0; + for (let i = 0; i < count; i++) worst = Math.max(worst, await timedRequest(server)); + return worst; +} + +afterAll(async () => { + await shutdownTelemetry(1_000); + vi.unstubAllEnvs(); +}); + +// WHY: a dead collector must cost a request nothing it waits on: every export is batched and +// asynchronous, so the request path never blocks on a connection that will not open. +describe("an unreachable endpoint", () => { + it("delays a request by at most 50 ms", async () => { + const server = await serve(); + try { + await timedRequest(server); + const baseline = await slowest(server, 5); + + vi.stubEnv("OTEL_EXPORTER_OTLP_ENDPOINT", UNREACHABLE); + await startTelemetry({ + serviceName: "acp-api", + esmLoaderHook: false, + env: { OTEL_EXPORTER_OTLP_ENDPOINT: UNREACHABLE, OTEL_LOG_LEVEL: "NONE" }, + }); + expect(isTelemetryStarted()).toBe(true); + // Fill a log batch (64) and a span batch (512) so exports to the dead collector are in flight. + for (let i = 0; i < 600; i++) trace.getTracer("probe").startSpan("filler").end(); + for (let i = 0; i < 70; i++) log.info({ event: "filler" }, "filler"); + await new Promise((resolve) => setTimeout(resolve, 20)); + + const withTelemetry = await slowest(server, 20); + expect(withTelemetry - baseline).toBeLessThanOrEqual(50); + } finally { + server.close(); + } + }, 15_000); +}); diff --git a/src/__tests__/url-credentials-entry.test.ts b/src/__tests__/url-credentials-entry.test.ts new file mode 100644 index 0000000..fd52033 --- /dev/null +++ b/src/__tests__/url-credentials-entry.test.ts @@ -0,0 +1,51 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const SRC = new URL("../", import.meta.url); +const IMPORT = /\b(?:import|export)\s[^'"]*?from\s*["']([^"']+)["']|\bimport\s*\(\s*["']([^"']+)["']/g; +const NODE_GLOBALS = /\b(?:process|Buffer|require|__dirname|__filename|global)\b/; + +/** Every module `entry` reaches through static or dynamic imports, with its source. */ +function importGraph(entry: string): Map { + const seen = new Map(); + const pending = [entry]; + while (pending.length > 0) { + const file = pending.pop()!; + if (seen.has(file)) continue; + const source = readFileSync(new URL(file, SRC), "utf8"); + seen.set(file, source); + for (const match of source.matchAll(IMPORT)) { + const specifier = (match[1] ?? match[2])!; + if (!specifier.startsWith("./")) { + seen.set(`external:${specifier}`, ""); + continue; + } + pending.push(specifier.replace(/\.js$/, ".ts").slice(2)); + } + } + return seen; +} + +// WHY: the browser bundles the `./url-credentials` entry. One import of a Node built-in or of +// the OTel SDK would pull server code into a client bundle, or break the build. +describe("the url-credentials entry", () => { + const graph = importGraph("url-credentials.ts"); + + it("imports no package and no Node built-in, only its own files", () => { + expect([...graph.keys()].filter((key) => key.startsWith("external:"))).toEqual([]); + }); + + it("uses no Node global in any file it reaches", () => { + for (const [file, source] of graph) { + const code = source.replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, ""); + expect(NODE_GLOBALS.test(code), file).toBe(false); + } + }); + + it("is exported as its own subpath", () => { + const manifest = JSON.parse( + readFileSync(new URL("../../package.json", import.meta.url), "utf8"), + ) as { exports: Record }; + expect(manifest.exports["./url-credentials"]?.default).toBe("./dist/url-credentials.js"); + }); +}); diff --git a/src/__tests__/userinfo-cases.ts b/src/__tests__/userinfo-cases.ts new file mode 100644 index 0000000..f6a36f7 --- /dev/null +++ b/src/__tests__/userinfo-cases.ts @@ -0,0 +1,194 @@ +// URL userinfo cases for the scrub tests: [input, expected]. + +/** Free text through `scrubMessage`. */ +export const MESSAGE_USERINFO_CASES: readonly (readonly [string, string])[] = [ + // Userinfo ends at the last `@` of the authority. + ["connect postgres://u:pw1@pw2.ab@db:5432/x failed", "connect postgres://db:5432/x failed"], + ["postgres://user:p@ss@db:5432/x", "postgres://db:5432/x"], + ["postgres://user:jane.doe@corp.com@db/x", "postgres://db/x"], + // `?`, `#` or `/` in a password. + ["postgres://user:p?ss@db/x", "postgres://db/x"], + ["postgres://user:p#ss@db/x", "postgres://db/x"], + ["postgres://u:p/ss@db/x", "postgres://db/x"], + ["https://medium.com/@alice", "https://medium.com/@alice"], + ["https://host:8080/@handle", "https://host:8080/@handle"], + ["https://h/p?x=a@b.com", "https://h/p"], + ["https://h/pkg@1.2.3", "https://h/pkg@1.2.3"], + // Every URL in a whitespace-free token. + ['{"a":"http://x","b":"postgres://u:p@db/x"}', '{"a":"http://x","b":"postgres://db/x"}'], + ["http://h/p,postgres://u:p@db/x", "http://h/p,postgres://db/x"], + // Scheme-relative, at the token start or after a delimiter. + ["//user:pass@host/x", "//host/x"], + ['"//user:pass@host/x"', '"//host/x"'], + ["(//u:p@h/x)", "(//h/x)"], + ["url=//u:p@h/x", "url=//h/x"], + ["/a//b", "/a//b"], + ["// note", "// note"], + // A URL ends at whitespace, a quote or an angle bracket, never at `,` or `;`. + [ + '{"baseUrl":"https://api.example.com","to":"jane@x.io","msg":"sent"}', + '{"baseUrl":"https://api.example.com","to":"[redacted]","msg":"sent"}', + ], + [ + '{"url":"http://localhost:3000","owner":"bob@x.io"}', + '{"url":"http://localhost:3000","owner":"[redacted]"}', + ], + ["'https://h','a@b.io'", "'https://h','[redacted]'"], + ["jane@x.io", "[redacted]"], + ["`https://h`jane@x.io", "`https://h`[redacted]"], + ["url=https://api.x.com,user=jane@x.io", "url=https://x.io"], + ["webhook https://hooks.slack.com;by=ops@x.io", "webhook https://x.io"], + ["postgres://u:p,q;r@db/x", "postgres://db/x"], + // Colonless userinfo with a `?` or `#` before its `@` is a credential. + ["https://ghp_ab#c@github.com/x", "https://github.com/x"], + ["https://tok?c@github.com/x", "https://github.com/x"], + ["redis://p#ss@r:6379", "redis://r:6379"], + ["https://h?u=a@b", "https://b"], + // A host and port, or a bracketed IPv6 host, before a `/`, `?` or `#` is never userinfo. + ["http://[::1]:80/p?u=a@b.com", "http://[::1]:80/p"], + ["http://[::1]:80/", "http://[::1]:80/"], + ["https://h:/p?u=a@b", "https://h:/p"], + ["https://h:8080?u=a@b.com", "https://h:8080"], + // A `//` or `://` inside a password starts no URL. + ["postgres://u:p,//x@db/x", "postgres://db/x"], + ["postgres://u:ab://c@db/x", "postgres://db/x"], + ["http://h,//u:p@db/x", "http://h,//db/x"], + // A quote ends a URL only at the token's end or before a delimiter. + ["postgres://u:it's@db/x", "postgres://db/x"], + ['{"dsn":"postgres://u:p\\"x@db/x"}', '{"dsn":"postgres://db/x"}'], + ['postgresql://app:p"w@db:5432/x', "postgresql://db:5432/x"], + [ + "P1001: Can't reach database server at postgresql://app:p\"w@db:5432/x", + "P1001: Can't reach database server at postgresql://db:5432/x", + ], + ['["https://h","x@y.io"]', '["https://h","[redacted]"]'], + // A `host:port` head before `?` or `#` needs a `=` or `/` before the `@`. + ["postgres://admin:2024#Secure@db/x", "postgres://db/x"], + ["redis://:12345#abc@r:6379", "redis://r:6379"], + ["redis://:1234?ab@r:6379", "redis://r:6379"], + // A `\` is host evidence only as an escaped `\/`. + ["postgres://app:\\abc/def@db/x", "postgres://db/x"], + ["postgres://app:\\abc=def@db/x", "postgres://db/x"], + ["postgres://app:1\\a/b@db/x", "postgres://db/x"], + // A `'` before `)` or `'` does not end userinfo; it still ends a host and path. + [ + "connect ECONNREFUSED postgres://app:oi0%7DDo%40%23b!c')%22!36%2B'5@db.internal:5432/app", + "connect ECONNREFUSED postgres://db.internal:5432/app", + ], + [ + "postgres://app:%22%7D0f~gTp3U2WRWy7''Qk@db.internal:5432/app", + "postgres://db.internal:5432/app", + ], + // Accepted cost: a quoted URL beside a quoted email loses its host. + ["('https://h'),('x@y.io')", "('https://y.io')"], + // `;` starts the path-parameter and query cut. + ["https://h/x;jsessionid=S", "https://h/x"], + ["GET https://h/p;jsessionid=A 200", "GET https://h/p 200"], + ["/cb;jsessionid=A?x=1 hit", "/cb hit"], + // One-slash, prefixed and JSON-escaped schemes. + ["https:/u:p@h/x", "https:/h/x"], + ["postgres:\\/\\/u:p@db\\/x", "postgres:\\/\\/db\\/x"], + // Only a space or a line break ends userinfo; a tab or other space ends a host and path. + ["postgres://u:p\tq@db/x", "postgres://db/x"], + ["x https://u:p\u2028q@h/x", "x https://h/x"], + ["postgres://u:p\u00a0q@db/x", "postgres://db/x"], + ["a://u:p@h\tb://v:q@i", "a://h\tb://i"], + ["https://h/p?q=1\tstatus=ok", "https://h/p\tstatus=ok"], + // A line break ends the URL, so userinfo split across it stays. + ["postgres://u:p\nass@db", "postgres://u:p\nass@db"], + // Unchanged behavior. + ["postgres://user:p%40ss@db/x", "postgres://db/x"], + ["postgres://user:p:ss@db/x", "postgres://db/x"], + ["https://ghp_X@github.com/o/r", "https://github.com/o/r"], + ["redis://:pass@cache:6379", "redis://cache:6379"], + ["amqp://guest:guest@mq/v", "amqp://mq/v"], + ["mongodb+srv://u:p@cluster0/db", "mongodb+srv://cluster0/db"], + ["jdbc:postgresql://u:p@db/x", "jdbc:postgresql://db/x"], + ["1postgres://u:p@db/x", "1postgres://db/x"], + ["x:postgres://u:p@db/x", "x:postgres://db/x"], + ['"postgres://u:p@db/x"', '"postgres://db/x"'], + ["(postgres://u:p@db/x)", "(postgres://db/x)"], + ["", ""], + ['{"url":"postgres://u:p@db/x"}', '{"url":"postgres://db/x"}'], + ["url=postgres://u:p@db/x", "url=postgres://db/x"], + ["a://u:p@h b://v:q@i", "a://h b://i"], + ["a://u:p@h\nb://v:q@i", "a://h\nb://i"], + ["GET HTTPS://user:pass@h/p?q=1 failed", "GET HTTPS://h/p failed"], + ["Https://user:pass@h/p", "Https://h/p"], + ["https://jane.doe@gmail.com/p", "https://gmail.com/p"], + ["sent to a@b.com", "sent to [redacted]"], + ["ssh deploy@buildbox failed", "ssh deploy@buildbox failed"], +]; + +/** A URL value through `stripQuery`. */ +export const URL_USERINFO_CASES: readonly (readonly [string, string])[] = [ + ["postgres://u:pw1@pw2.ab@db:5432/x", "postgres://db:5432/x"], + ["postgres://user:p@ss@db:5432/x", "postgres://db:5432/x"], + ["postgres://user:p?ss@db/x", "postgres://db/x"], + ["postgres://user:p#ss@db/x", "postgres://db/x"], + ["postgres://u:p/ss@db/x", "postgres://db/x"], + ["//user:pass@host/x?q=1", "//host/x"], + ["https://medium.com/@alice", "https://medium.com/@alice"], + ["https://host:8080/@handle?q", "https://host:8080/@handle"], + ["https://user:pass@h/p?q=1#frag", "https://h/p"], + ["HTTPS://user:pass@h/p?q", "HTTPS://h/p"], + ["/a//b@c?q", "/a//b@c"], + ["https://ghp_ab#c@github.com/x", "https://github.com/x"], + ["redis://p#ss@r:6379", "redis://r:6379"], + ["http://[::1]:80/p?u=a@b.com", "http://[::1]:80/p"], + ["https://h:/p?u=a@b", "https://h:/p"], + ["postgres://u:p,//x@db/x", "postgres://db/x"], + ["postgres://u:ab://c@db/x", "postgres://db/x"], + // Leading whitespace and a scheme chain. A value with no `//` keeps its text before an `@`. + [" https://u:p@h/x", " https://h/x"], + ["\thttps://u:p@h/x", "\thttps://h/x"], + ["jdbc:postgresql://u:p@h/db", "jdbc:postgresql://h/db"], + ["mailto:jane@example.com", "mailto:jane@example.com"], + ["postgres://admin:2024#Secure@db/x", "postgres://db/x"], + ["redis://:12345#abc@r:6379", "redis://r:6379"], + ["https://h:8080?u=a@b.com", "https://h:8080"], + ["postgres://app:\\abc/def@db/x", "postgres://db/x"], + ["https://h/x;jsessionid=S", "https://h/x"], + ["postgres://u:p,q;r@db/x", "postgres://db/x"], + ["https:/u:p@h/x", "https:/h/x"], + ["1postgres://u:p@db/x", "1postgres://db/x"], + ['postgres://u:p"x@db/x', "postgres://db/x"], +]; + +/** A span name through `scrubSpanName`, with no route. */ +export const SPAN_NAME_CASES: readonly (readonly [string, string])[] = [ + ["GET /x?t=1", "GET /x"], + ["GET /x?t=1\n", "GET /x\n"], + ["fetch GET https://u:p@h/x?t", "fetch GET https://h/x"], + ["GET /apply/SECRET", "GET /apply/[token]"], + ["GET /APPLY/SECRET?x", "GET /apply/[token]"], + ["render route (app) /apply/[token]", "render route (app) /apply/[token]"], + ["GET u:p@h/x?t=1", "GET h/x"], + ["GET https://h/p;jsessionid=A", "GET https://h/p"], + // Accepted cost: a scheme-less `x@` reads as userinfo. + ["GET pkg@1.2.3", "GET 1.2.3"], +]; + +/** A URL attribute through `scrubAttributes`, with no route: [key, value, expected]. */ +export const URL_ATTRIBUTE_CASES: readonly (readonly [string, string, string])[] = [ + ["url.full", "https://ghp_ab#c@github.com/x", "https://github.com/x"], + ["http.target", "https://ghp_ab#c@github.com/x", "https://github.com/x"], + ["url.full", " https://u:p@h/x", " https://h/x"], + ["url.full", " https://h/apply/SECRET", " https://h/apply/[token]"], + ["http.target", "\t/apply/SECRET", "\t/apply/[token]"], + ["url.full", "jdbc:postgresql://u:p@h/db", "jdbc:postgresql://h/db"], + ["http.url", "u:p@h/x", "h/x"], + ["url.full", "u:p@h/x?q", "h/x"], + ["url.full", "https:/u:p@h/x", "https:/h/x"], + ["url.full", "1postgres://u:p@db/x", "1postgres://db/x"], + ["url.full", "https://h/x;jsessionid=S", "https://h/x"], + ["url.full", "https://h//apply/SECRET", "https://h/apply/[token]"], + ["http.target", "/APPLY/SECRET", "/apply/[token]"], + ["url.full", "//h/apply/SECRET", "//h/apply/[token]"], + ["http.target", "//apply/SECRET?x", "/apply/[token]"], + [ + "url.full", + "https://h/v1//Applications/by-token/SECRET", + "https://h/v1/applications/by-token/:token", + ], +]; diff --git a/src/app-keys.ts b/src/app-keys.ts new file mode 100644 index 0000000..00d7af4 --- /dev/null +++ b/src/app-keys.ts @@ -0,0 +1,103 @@ +/** App-supplied key names, lower-cased; redacted in log fields and span attributes. */ +const appKeys = new Set(); + +/** Adds key names (matched whole, any letter case); throws on a key that is not a string. */ +export function addAppKeys(keys: Iterable): void { + const names = [...keys].map((key) => { + if (typeof key !== "string") throw new TypeError("redactKeys must hold strings"); + return key.trim().toLowerCase(); + }); + for (const name of names) if (name) appKeys.add(name); +} + +/** Test seam: forgets the app-supplied keys. */ +export function resetAppKeys(): void { + appKeys.clear(); +} + +/** True when `key` is an app-supplied key name, in any letter case. */ +export function isAppKey(key: string): boolean { + return appKeys.size > 0 && appKeys.has(key.toLowerCase()); +} + +/** + * Attributes the package's instrumentations, BullMQ and Prisma emit whose last segment is a + * plausible generic app key (`name`, `id`, `method`). Only these exact names skip last-segment + * matching, so `name` never wipes `db.system.name`, while an app's own `http.unitLabel` is still + * redacted. Content-bearing attributes (`url.full`, `db.query.text`, job options and reasons) are + * deliberately absent. Prisma's bare `name`, `method` and `model` are not exempt: a whole-name app + * key matches them. + */ +export const EXEMPT_ATTRIBUTES: ReadonlySet = new Set([ + // http and undici + "http.request.method", + "http.request.method_original", + "http.response.status_code", + "http.route", + "server.address", + "server.port", + "network.peer.port", + "network.protocol.version", + "url.scheme", + "error.type", + "user_agent.synthetic.type", + // ioredis, pg and Prisma + "db.system.name", + "db.operation.name", + "db.namespace", + "db.client.connection.pool.name", + "db.client.connection.state", + // messaging semantic conventions + "messaging.destination.name", + // bullmq: names and identifiers + "bullmq.queue.name", + "bullmq.queue.operation", + "bullmq.job.name", + "bullmq.job.id", + "bullmq.job.ids", + "bullmq.job.key", + "bullmq.job.type", + "bullmq.job.state", + "bullmq.job.scheduler.id", + "bullmq.worker.name", + "bullmq.worker.id", + "bullmq.flow.name", + "bullmq.queue.jobs.state", + // bullmq: counts and timings + "bullmq.job.attempts.made", + "bullmq.job.bulk.count", + "bullmq.job.processed.timestamp", + "bullmq.job.attempt_finished_timestamp", + "bullmq.job.duration", + "bullmq.queue.drain.delay", + "bullmq.queue.grace", + "bullmq.queue.clean.limit", + "bullmq.queue.clean.count", + "bullmq.queue.rate.limit", + "bullmq.queue.event.max.length", + "bullmq.worker.rate.limit", + "bullmq.worker.do.not.wait.active", + "bullmq.worker.force.close", + "bullmq.worker.stalled.jobs", + "bullmq.worker.failed.jobs", + "bullmq.worker.jobs.to.extend.locks", + "bullmq.jobs.completed", + "bullmq.jobs.failed", + "bullmq.jobs.delayed", + "bullmq.jobs.retried", + "bullmq.jobs.waiting", + "bullmq.jobs.waiting_children", + // exception events + "exception.type", +]); + +/** + * A span attribute is an app key when its whole name is one, or its last `.` segment is one, + * unless it is an exempt instrumentation attribute. + */ +export function isAppAttributeKey(key: string): boolean { + if (appKeys.size === 0) return false; + if (isAppKey(key)) return true; + if (EXEMPT_ATTRIBUTES.has(key)) return false; + return isAppKey(key.slice(key.lastIndexOf(".") + 1)); +} diff --git a/src/bounded.ts b/src/bounded.ts new file mode 100644 index 0000000..6659983 --- /dev/null +++ b/src/bounded.ts @@ -0,0 +1,18 @@ +/** The label every value outside an allowlist collapses into. */ +export const OTHER_LABEL = "other"; + +/** + * A metric label from a closed set: the value itself when the allowlist holds it, otherwise + * `"other"`. Keeps raw paths, ids and error text from ever becoming a label value. + */ +export function boundedLabel( + value: unknown, + allowlist: ReadonlySet | readonly T[], +): T | typeof OTHER_LABEL { + if (typeof value !== "string") return OTHER_LABEL; + const allowed = + allowlist instanceof Set + ? allowlist.has(value as T) + : (allowlist as readonly string[]).includes(value); + return allowed ? (value as T) : OTHER_LABEL; +} diff --git a/src/bullmq.ts b/src/bullmq.ts new file mode 100644 index 0000000..2febaa2 --- /dev/null +++ b/src/bullmq.ts @@ -0,0 +1,10 @@ +import type { Telemetry } from "bullmq"; +import { startedBullmqTelemetry } from "./sdk.js"; + +/** + * BullMQ telemetry adapter for a Queue or Worker `telemetry` option. Undefined until + * telemetry has started, which leaves BullMQ on its untraced code path. + */ +export function bullmqTelemetry(): Telemetry | undefined { + return startedBullmqTelemetry() as Telemetry | undefined; +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..fc45a11 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,33 @@ +import { randomUUID } from "node:crypto"; + +export type TelemetryEnv = Partial>; + +export interface TelemetryConfig { + enabled: boolean; + serviceName: string; + serviceVersion: string; + environment: string; + serviceInstanceId: string; +} + +/** One id per process, so every signal from this process names the same instance. */ +const PROCESS_INSTANCE_ID = randomUUID(); + +/** Telemetry exports only when an OTLP endpoint is set; local runs, CI and tests stay silent. */ +export function resolveTelemetryConfig( + fallbackServiceName: string, + env: TelemetryEnv = process.env, +): TelemetryConfig { + const endpoint = env.OTEL_EXPORTER_OTLP_ENDPOINT?.trim(); + const killed = env.OTEL_SDK_DISABLED?.trim().toLowerCase() === "true"; + return { + enabled: Boolean(endpoint) && !killed, + serviceName: env.OTEL_SERVICE_NAME?.trim() || fallbackServiceName, + // A `railway up` deploy carries no commit SHA; the deployment id still pins it. + serviceVersion: + env.RAILWAY_GIT_COMMIT_SHA?.trim() || env.RAILWAY_DEPLOYMENT_ID?.trim() || "unknown", + environment: env.RAILWAY_ENVIRONMENT_NAME?.trim() || "unknown", + // Splits runtime panels by replica; Prometheus maps it to the `instance` label. + serviceInstanceId: env.RAILWAY_REPLICA_ID?.trim() || PROCESS_INSTANCE_ID, + }; +} diff --git a/src/console-capture.ts b/src/console-capture.ts new file mode 100644 index 0000000..545b73f --- /dev/null +++ b/src/console-capture.ts @@ -0,0 +1,86 @@ +import { formatWithOptions } from "node:util"; +import type { Logger } from "pino"; +import { createLogger } from "./logger.js"; +import { safeErrorMessage } from "./log-errors.js"; +import { sanitizeLogValue } from "./log-redact.js"; +import { scrubErrorText } from "./log-scrub.js"; +import { hasLogLineSink } from "./log-sink.js"; +import { currentService } from "./shared-log.js"; + +const LEVELS = ["warn", "error"] as const; +type Level = (typeof LEVELS)[number]; +type ConsoleMethod = (...args: unknown[]) => void; + +let originals: Partial> | undefined; + +/** + * Sends every `console.warn` and `console.error` line to OTLP logs, through the logger's scrub and + * under the configured log scope, and still writes it to the console unchanged. Only the scrubbed + * copy is exported; stdout keeps the app's own line. Calling it again changes nothing. + */ +export function captureConsole(): void { + if (originals) return; + originals = {}; + // Writes nothing itself: the line reaches only the OTLP sink. + const logger = createLogger(currentService, { write: () => undefined }).child({ + component: "console", + }); + for (const level of LEVELS) { + const original = console[level] as ConsoleMethod; + originals[level] = original; + console[level] = (...args: unknown[]): void => { + original.apply(console, args); + exportLine(logger, level, args); + }; + } +} + +/** Puts back the console methods `captureConsole` replaced. */ +export function releaseConsole(): void { + if (!originals) return; + for (const level of LEVELS) { + const original = originals[level]; + if (original) console[level] = original; + } + originals = undefined; +} + +/** + * The first Error goes under `err`, and its safe message takes its place in the text, so a `%s` + * still reads as on the console. Every other argument is reduced to what the logger would export + * for it, then the arguments are formatted, and the rendered text is scrubbed last: a value split + * across arguments only takes its final shape once formatted. + */ +function exportLine(logger: Logger, level: Level, args: unknown[]): void { + if (!hasLogLineSink()) return; + try { + const err = args.find((arg) => arg instanceof Error); + const parts = args.map((arg) => (arg === err ? safeErrorMessage(err) : asLogged(arg))); + const message = scrubErrorText(formatWithOptions({ colors: false }, ...parts)); + if (err === undefined) logger[level](message); + else logger[level]({ err }, message); + } catch { + // the console line is already written + } +} + +/** + * An argument as the logger's JSON line would carry it: sanitized (bounded, PII keys redacted), + * then through JSON, so console formatting has nothing left to print that the line would not + * hold. No function, Map or Set contents, getter, custom inspect or toString survives. + */ +function asLogged(arg: unknown): unknown { + if (typeof arg === "function") return "[Function]"; + const sanitized = sanitizeLogValue(arg); + if (typeof sanitized !== "object" || sanitized === null) return sanitized; + const json = JSON.stringify(sanitized, jsonSafe); + return json === undefined ? undefined : (JSON.parse(json) as unknown); +} + +/** A byte view becomes its size, a Map or Set `{}`, a bigint its digits. */ +function jsonSafe(_key: string, value: unknown): unknown { + if (typeof value === "bigint") return value.toString(); + if (ArrayBuffer.isView(value)) return { bytes: value.byteLength }; + if (value instanceof Map || value instanceof Set) return {}; + return value; +} diff --git a/src/fatal.ts b/src/fatal.ts new file mode 100644 index 0000000..683637a --- /dev/null +++ b/src/fatal.ts @@ -0,0 +1,65 @@ +import { flushLog, log } from "./shared-log.js"; +import { reportError } from "./options.js"; + +/** Total time a fatal exit may spend flushing logs and telemetry. */ +const FATAL_FLUSH_MS = 2_000; + +interface FatalOptions { + /** Synchronous: stop taking new work (a BullMQ `worker.close()`, not awaited). */ + stopIntake?: () => void; + /** + * Reports the fatal error; called once, before the flush. Defaults to the `onError` hook given + * to `startTelemetry`. + */ + report?: (err: unknown) => void; + /** Flushes telemetry; raced against the same budget as the log flush. */ + flushTelemetry?: (timeoutMs: number) => Promise; + exit?: (code: number) => void; + target?: Pick; +} + +/** + * Logs an uncaught exception or unhandled rejection as one JSON line under `err`, stops intake, + * reports it, spends at most two seconds flushing, then exits 1. A second fatal while exiting is + * logged only. + */ +export function installFatalHandlers(options: FatalOptions = {}): void { + const { + stopIntake, + report = (err) => reportError(err), + flushTelemetry, + exit = (code) => process.exit(code), + } = options; + const target = options.target ?? process; + let exiting = false; + + const fatal = (err: unknown, message: string): void => { + try { + log.fatal({ err }, message); + } catch { + // the exit still has to happen + } + if (exiting) return; + exiting = true; + process.exitCode = 1; + try { + stopIntake?.(); + } catch { + // a failing close must not keep a crashed process alive + } + try { + report(err); + } catch { + // reporting must not keep a crashed process alive + } + const budget = new Promise((resolve) => setTimeout(resolve, FATAL_FLUSH_MS).unref()); + const flushes = Promise.allSettled([ + flushLog(FATAL_FLUSH_MS), + Promise.resolve().then(() => flushTelemetry?.(FATAL_FLUSH_MS)), + ]); + void Promise.race([flushes, budget]).then(() => exit(1)); + }; + + target.on("unhandledRejection", (reason) => fatal(reason, "unhandled rejection")); + target.on("uncaughtException", (err) => fatal(err, "uncaught exception")); +} diff --git a/src/http-route.ts b/src/http-route.ts new file mode 100644 index 0000000..dfeff0b --- /dev/null +++ b/src/http-route.ts @@ -0,0 +1,16 @@ +import { context, trace } from "@opentelemetry/api"; +import { getRPCMetadata, RPCType } from "@opentelemetry/core"; +import { logScope } from "./options.js"; + +/** + * Labels the active HTTP server span and its request metrics with a route template, and tags the + * span with `.request_id`. Call it once routing has finished; a no-op when no span or + * HTTP context is active. + */ +export function setHttpRoute(route: string, method: string, requestId?: string): void { + const rpc = getRPCMetadata(context.active()); + if (rpc?.type === RPCType.HTTP) rpc.route = route; + const span = trace.getActiveSpan(); + span?.updateName(`${method} ${route}`); + if (requestId) span?.setAttribute(`${logScope()}.request_id`, requestId); +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..94db880 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,10 @@ +export { startTelemetry, shutdownTelemetry, isTelemetryStarted } from "./sdk.js"; +export type { TelemetryConfig, TelemetryEnv } from "./config.js"; +export type { ErrorHookContext, MetricViewOptions, TelemetryOptions } from "./options.js"; +export { setHttpRoute } from "./http-route.js"; +export { getMeter, type BatchObservableResult, type Meter } from "./meter.js"; +export { log, configureLogger, flushLog } from "./shared-log.js"; +export { captureConsole } from "./console-capture.js"; +export { installFatalHandlers } from "./fatal.js"; +export { boundedLabel } from "./bounded.js"; +export { currentTraceparent, withLinkedSpan, withRootSpan } from "./trace-context.js"; diff --git a/src/instrumentations.ts b/src/instrumentations.ts new file mode 100644 index 0000000..f463d13 --- /dev/null +++ b/src/instrumentations.ts @@ -0,0 +1,35 @@ +import type { Instrumentation } from "@opentelemetry/instrumentation"; +import { HttpInstrumentation } from "@opentelemetry/instrumentation-http"; +import { UndiciInstrumentation } from "@opentelemetry/instrumentation-undici"; +import { IORedisInstrumentation } from "@opentelemetry/instrumentation-ioredis"; +import { PgInstrumentation } from "@opentelemetry/instrumentation-pg"; +import { RuntimeNodeInstrumentation } from "@opentelemetry/instrumentation-runtime-node"; + +/** An ignored path covers itself and everything below it, never a sibling sharing a prefix. */ +function isIgnored(url: string | undefined, ignoredPaths: readonly string[]): boolean { + const path = (url ?? "").split("?")[0]!; + return ignoredPaths.some((ignored) => path === ignored || path.startsWith(`${ignored}/`)); +} + +/** + * Hand-picked list: the auto-instrumentations bundle adds fs, dns and net spans that are noise. + * The app's own instrumentations (Prisma, at the app's version) come last. + */ +export function buildInstrumentations( + ignoredPaths: readonly string[], + appInstrumentations: readonly Instrumentation[] = [], +): Instrumentation[] { + return [ + // Event-loop delay, heap, GC. Its uncaught-exception capture stays off: it would emit the raw, + // unscrubbed exception message and stack as a log record. + new RuntimeNodeInstrumentation({ captureUncaughtException: false }), + new HttpInstrumentation({ + ignoreIncomingRequestHook: (request) => isIgnored(request.url, ignoredPaths), + }), + new UndiciInstrumentation(), + new IORedisInstrumentation({ requireParentSpan: true }), + // SQL text only, never bound values. + new PgInstrumentation({ enhancedDatabaseReporting: false, requireParentSpan: true }), + ...appInstrumentations, + ]; +} diff --git a/src/log-errors.ts b/src/log-errors.ts new file mode 100644 index 0000000..a476959 --- /dev/null +++ b/src/log-errors.ts @@ -0,0 +1,194 @@ +import { scrubErrorText, scrubStack } from "./log-scrub.js"; + +const MAX_CAUSE_DEPTH = 10; +const MAX_AGGREGATE_ERRORS = 20; +/** The only properties any error keeps, besides type, name, message, stack, cause and errors. */ +const COMMON_KEYS = ["code", "errorCode", "status", "statusCode"] as const; + +type ErrorRecord = Record; + +interface Family { + /** Extra primitive properties this family keeps. */ + keys: readonly string[]; + /** A value-free message, for families whose message embeds field values. */ + message?: (err: ErrorRecord, name: string) => string; + /** The cause carries the raw driver text; its code is already in the message. */ + dropCause?: boolean; +} + +function read(target: unknown, key: string): unknown { + try { + return (target as ErrorRecord)[key]; + } catch { + return undefined; + } +} + +function asRecord(value: unknown): ErrorRecord | undefined { + return typeof value === "object" && value !== null ? (value as ErrorRecord) : undefined; +} + +function str(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +const PRISMA: Family = { + keys: [], + message: (err, name) => { + const code = str(read(err, "code")) ?? str(read(err, "errorCode")); + return code ? `${name} ${code}` : name; + }, +}; +const DRIVER_ADAPTER: Family = { + keys: [], + dropCause: true, + message: (err, name) => { + const cause = read(err, "cause"); + const code = str(read(cause, "originalCode")) ?? str(read(cause, "kind")); + return code ? `${name} ${code}` : name; + }, +}; +const ZOD: Family = { + keys: [], + message: (err, name) => { + const issues = read(err, "issues"); + return `${name} ${Array.isArray(issues) ? issues.length : 0} issue(s)`; + }, +}; +const STRIPE: Family = { keys: ["decline_code", "param", "requestId"] }; +const HAPPYCO: Family = { keys: ["outcome"] }; +const FUNNEL: Family = { keys: ["api", "errorType"] }; +const GENERIC: Family = { keys: [] }; + +function familyOf(name: string, err: ErrorRecord): Family { + if (name.startsWith("Prisma")) return PRISMA; + if (name === "DriverAdapterError") return DRIVER_ADAPTER; + if (name === "ZodError") return ZOD; + if (name.startsWith("Stripe") || str(read(err, "type"))?.startsWith("Stripe")) return STRIPE; + if (name.startsWith("HappyCo")) return HAPPYCO; + if (name.startsWith("Funnel")) return FUNNEL; + return GENERIC; +} + +function typeOf(err: ErrorRecord, name: string | undefined): string { + if (err instanceof Error) { + const ctor = read(err, "constructor"); + const ctorName = typeof ctor === "function" ? ctor.name : undefined; + if (ctorName) return ctorName; + } + return name ?? "Error"; +} + +function primitive(value: unknown): string | number | boolean | undefined { + if (typeof value === "string") return scrubErrorText(value); + return typeof value === "number" || typeof value === "boolean" ? value : undefined; +} + +/** Prisma's meta names the failing column or model; every other meta field is a value. */ +function prismaMeta(err: ErrorRecord): ErrorRecord | undefined { + const meta = asRecord(read(err, "meta")); + if (!meta) return undefined; + const target = read(meta, "target"); + const modelName = read(meta, "modelName"); + const out: ErrorRecord = {}; + // Column names only: an array keeps its strings, so no caller object reaches the serializer. + if (typeof target === "string") out.target = target; + else if (Array.isArray(target)) out.target = target.filter((item) => typeof item === "string"); + if (typeof modelName === "string") out.modelName = modelName; + return out; +} + +function sanitizeValue(value: unknown, depth: number, ancestors: WeakSet): unknown { + const record = asRecord(value); + if (!record) return primitive(value); + if (ancestors.has(record)) return "[circular]"; + if (depth >= MAX_CAUSE_DEPTH) return "[truncated]"; + return sanitize(record, depth, ancestors); +} + +/** A caller-built error-like object's name is free text; scrub it like a message. */ +function nameOf(err: ErrorRecord): { raw?: string; name?: string } { + const raw = str(read(err, "name")); + return { raw, name: raw === undefined ? undefined : scrubErrorText(raw) }; +} + +/** Deny by default: an error keeps only the fields listed here; every other property is dropped. */ +function sanitize(err: ErrorRecord, depth: number, ancestors: WeakSet): ErrorRecord { + ancestors.add(err); + try { + return sanitizeFields(err, depth, ancestors); + } finally { + ancestors.delete(err); + } +} + +function sanitizeFields(err: ErrorRecord, depth: number, ancestors: WeakSet): ErrorRecord { + const { raw, name } = nameOf(err); + const family = familyOf(raw ?? "", err); + const out: ErrorRecord = { type: typeOf(err, name) }; + if (name !== undefined) out.name = name; + + const safe = family.message?.(err, name ?? "Error"); + const rawMessage = str(read(err, "message")); + if (safe !== undefined) out.message = safe; + else if (rawMessage !== undefined) out.message = scrubErrorText(rawMessage); + + const stack = str(read(err, "stack")); + if (stack !== undefined) { + // A message that is not a string is rendered into the stack header by the runtime, through + // its own toString; the header then names the type only. + const header = + safe !== undefined + ? `${name ?? "Error"}: ${safe}` + : rawMessage === undefined && read(err, "message") !== undefined + ? out.type + : undefined; + out.stack = scrubStack(stack, header as string | undefined); + } + + for (const key of [...COMMON_KEYS, ...family.keys]) { + const value = primitive(read(err, key)); + if (value !== undefined) out[key] = value; + } + if (family === PRISMA) { + const meta = prismaMeta(err); + if (meta) out.meta = meta; + } + + const cause = family.dropCause ? undefined : read(err, "cause"); + if (cause !== undefined) { + const sanitized = sanitizeValue(cause, depth + 1, ancestors); + if (sanitized !== undefined) out.cause = sanitized; + } + const errors = read(err, "errors"); + if (Array.isArray(errors)) { + out.errors = errors + .slice(0, MAX_AGGREGATE_ERRORS) + .map((item) => sanitizeValue(item, depth + 1, ancestors) ?? null); + } + return out; +} + +/** + * The message safe to log for an error: code-only for value-embedding families, else scrubbed. + * An empty message falls back to the error type. + */ +export function safeErrorMessage(value: unknown): string { + try { + const err = asRecord(value); + if (!err) return scrubErrorText(String(value)); + const { raw, name } = nameOf(err); + const safe = familyOf(raw ?? "", err).message?.(err, name ?? "Error"); + // A string message only: String() would run a caller object's toString or toPrimitive. + const message = safe ?? scrubErrorText(str(read(err, "message")) ?? ""); + return message === "" ? typeOf(err, name) : message; + } catch { + return "[unserializable error]"; + } +} + +/** An allowlisted, scrubbed plain copy of an error or error-like value. May throw; see serializeError. */ +export function serializeErrorBase(value: unknown): unknown { + const err = asRecord(value); + return err ? sanitize(err, 0, new WeakSet()) : primitive(value); +} diff --git a/src/log-fields.ts b/src/log-fields.ts new file mode 100644 index 0000000..9b422e0 --- /dev/null +++ b/src/log-fields.ts @@ -0,0 +1,8 @@ +import { isSpanContextValid, trace } from "@opentelemetry/api"; + +/** Trace correlation fields for the active span; empty outside a valid span. */ +export function traceLogFields(): { trace_id?: string; span_id?: string } { + const spanContext = trace.getActiveSpan()?.spanContext(); + if (!spanContext || !isSpanContextValid(spanContext)) return {}; + return { trace_id: spanContext.traceId, span_id: spanContext.spanId }; +} diff --git a/src/log-message.ts b/src/log-message.ts new file mode 100644 index 0000000..0031906 --- /dev/null +++ b/src/log-message.ts @@ -0,0 +1,54 @@ +import { safeErrorMessage } from "./log-errors.js"; +import { plainFields, sanitizeLogValue } from "./log-redact.js"; +import { scrubMessage } from "./log-scrub.js"; + +const UNLOGGABLE = "[unloggable]"; + +/** pino derives `msg` from an Error first argument or from `err.message` of any object. */ +function errorIn(first: unknown): unknown { + if (first instanceof Error) return first; + if (typeof first !== "object" || first === null) return undefined; + const fields = first as Record; + for (const key of ["err", "error", "e"]) { + const candidate = fields[key]; + if (candidate instanceof Error) return candidate; + } + const err = fields.err; + return typeof err === "object" && err !== null && "message" in err ? err : undefined; +} + +/** + * Arguments for a pino log call with a sanitized `msg`: the raw error message never becomes `msg`, + * a `msg` field is scrubbed like a message string, and every format argument is sanitized exactly + * like the log object. Arguments that cannot be inspected become a placeholder line. + */ +export function sanitizeLogArgs(args: unknown[]): unknown[] { + try { + const out = [...args]; + // Converted once, before any msg handling, so a toJSON-hidden field never reaches a spread. + if (typeof out[0] === "object" && out[0] !== null && !(out[0] instanceof Error)) { + out[0] = plainFields(out[0]); + } + const first = out[0]; + const msgIndex = typeof first === "string" ? 0 : 1; + const fields = first as Record | null | undefined; + if (msgIndex === 1 && !(first instanceof Error) && typeof fields?.msg === "string") { + out[0] = { ...fields, msg: scrubMessage(fields.msg) }; + } + if (typeof out[msgIndex] !== "string" && msgIndex === 1) { + const err = errorIn(first); + if (err !== undefined) out.splice(1, 0, safeErrorMessage(err)); + else if (typeof fields?.msg === "string") { + const { msg, ...rest } = out[0] as Record; + out[0] = rest; + out.splice(1, 0, msg); + } + } + // The message and every format argument take the log object's own sanitizer. + for (let i = msgIndex; i < out.length; i++) out[i] = sanitizeLogValue(out[i]); + return out; + } catch { + // Nothing raw goes out: a format arg could carry PII the sanitizer never saw. + return [{ log_redaction_failed: true }, UNLOGGABLE]; + } +} diff --git a/src/log-otlp.ts b/src/log-otlp.ts new file mode 100644 index 0000000..a47289d --- /dev/null +++ b/src/log-otlp.ts @@ -0,0 +1,136 @@ +import { + isSpanContextValid, + ROOT_CONTEXT, + trace, + TraceFlags, + type Context, +} from "@opentelemetry/api"; +import { + SeverityNumber, + type LogAttributes, + type Logger, + type LogRecord, +} from "@opentelemetry/api-logs"; +import type { LogRecordProcessor, SdkLogRecord } from "@opentelemetry/sdk-logs"; +import type { LogLineSink } from "./log-sink.js"; + +const SEVERITY: Record = { + trace: SeverityNumber.TRACE, + debug: SeverityNumber.DEBUG, + info: SeverityNumber.INFO, + warn: SeverityNumber.WARN, + error: SeverityNumber.ERROR, + fatal: SeverityNumber.FATAL, +}; + +/** Body of a line with no message, so a text search in Loki still has something to match. */ +export const NO_MESSAGE = "(no message)"; + +/** + * OTel's own diagnostics (exporter failures among them). Exporting one would feed a failing + * exporter its own failures, once per batch, so they stay on stdout. + */ +function isOtelDiag(fields: Record): boolean { + return fields.component === "telemetry" && fields.event === "otel_diag"; +} + +/** The line's correlation fields as a log record context; flags come from the matching span. */ +function lineContext(traceId: unknown, spanId: unknown): Context | undefined { + if (typeof traceId !== "string" || typeof spanId !== "string") return undefined; + const active = trace.getActiveSpan()?.spanContext(); + const traceFlags = + active?.traceId === traceId && active.spanId === spanId ? active.traceFlags : TraceFlags.NONE; + const spanContext = { traceId, spanId, traceFlags }; + return isSpanContextValid(spanContext) + ? trace.setSpanContext(ROOT_CONTEXT, spanContext) + : undefined; +} + +function timestampOf(time: unknown): Date | undefined { + if (typeof time !== "string") return undefined; + const date = new Date(time); + return Number.isNaN(date.getTime()) ? undefined : date; +} + +/** + * The fields search and dashboards need, first: the SDK keeps a record's first 128 attributes and + * the logger writes `app` last. + */ +const PRIORITY_FIELDS = ["app", "env", "component", "event"]; + +function priorityFirst(fields: Record): LogAttributes { + const out: Record = {}; + for (const key of PRIORITY_FIELDS) if (key in fields) out[key] = fields[key]; + for (const [key, value] of Object.entries(fields)) if (!(key in out)) out[key] = value; + return out as LogAttributes; +} + +function bodyOf(msg: unknown, event: unknown): string { + if (typeof msg === "string" && msg !== "") return msg; + if (typeof event === "string" && event !== "") return event; + return NO_MESSAGE; +} + +/** + * A sink that turns each line the scrubbing logger wrote into an OTel log record: `msg` (else + * `event`) is the body, `level` the severity, `time` the timestamp, `trace_id`/`span_id` the record + * context, every other field an attribute. Only the serialized line is read, so a record holds + * nothing the line does not. The record is emitted after the line is written, so an SDK diagnostic + * about it (a dropped attribute) follows the line on stdout instead of preceding it. + */ +export function logLineSink(logger: Logger): LogLineSink { + return (line) => { + const parsed: unknown = JSON.parse(line); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return; + const { msg, level, time, trace_id, span_id, ...attributes } = parsed as Record< + string, + unknown + >; + if (isOtelDiag(attributes)) return; + const record: LogRecord = { + body: bodyOf(msg, attributes.event), + attributes: priorityFirst(attributes), + context: lineContext(trace_id, span_id) ?? ROOT_CONTEXT, + }; + if (typeof level === "string") { + record.severityText = level; + record.severityNumber = SEVERITY[level] ?? SeverityNumber.UNSPECIFIED; + } + const timestamp = timestampOf(time); + if (timestamp) record.timestamp = timestamp; + queueMicrotask(() => { + try { + logger.emit(record); + } catch { + // the line is already on stdout + } + }); + }; +} + +/** + * Forwards only records from `scope`, the one fed by the scrubbing logger. Any other emitter on + * the global provider (an instrumentation's raw exception record) is never exported. + */ +export class ScopedLogRecordProcessor implements LogRecordProcessor { + constructor( + private readonly inner: LogRecordProcessor, + private readonly scope: string, + ) {} + + onEmit(record: SdkLogRecord, context?: Context): void { + if (record.instrumentationScope.name === this.scope) this.inner.onEmit(record, context); + } + + enabled(options: Parameters>[0]): boolean { + return options.instrumentationScope.name === this.scope; + } + + forceFlush(): Promise { + return this.inner.forceFlush(); + } + + shutdown(): Promise { + return this.inner.shutdown(); + } +} diff --git a/src/log-redact.ts b/src/log-redact.ts new file mode 100644 index 0000000..242263f --- /dev/null +++ b/src/log-redact.ts @@ -0,0 +1,217 @@ +import { serializeError } from "./log-serializer.js"; +import { scrubMessage } from "./log-scrub.js"; +import { stripQuery } from "./redact.js"; +import { describePatterns } from "./scrub-descriptor.js"; +import { isAppKey } from "./app-keys.js"; + +// Matches PII and credential key names (camelCase, snake_case, kebab-case) at any depth. +// `ssn`, `dob` and `ein` are anchored so words such as "businessName", "adobe" or "protein" +// stay readable; a camelCase `...Ein` is matched case-sensitively in isSensitiveKey. +// A person's name is matched by its qualifier (`firstName`, `contactName`, `payeeName`). A bare +// `name` is not: queue, job, category and target names are logged under it. +// Any key ending in `address` is: in a provider payload it is a mailing address. That includes +// `ipAddress`, which only the access-audit rows store and no log call site emits. +// Structured address parts (`street1`, `line1`) and `mobile` are matched too. +const SENSITIVE_KEY_PATTERN = + /(^ssn|ssn$|ssn.?(enc|last)|_ssn|social.?security|^dob|dob$|dob.?enc|_dob|date.?of.?birth|birth.?date|account.?number|routing.?number|tax.?id|taxpayer|(^|_)ein(_?number)?$|e.?mail|phone|password|passwd|secret|token|api.?key|authorization|cookie|holder.?name|iban|passport|drivers?.?license|birthday|bank.?account|aba.?number|clerk.?auth|signature$|enc$|enc.?snapshot$|(first|last|middle|full|legal|preferred|given|family).?name|surname|(occupant|caller|contact|payee|bill.?to|tenant|resident|applicant|guarantor|performed.?by|assignee|recipient|signer).?name|(street|mailing|home|billing|residential|postal).?address|^address(.?line)?.?\d*$|address.?line|address$|forwarding.?address|^street\d*$|^line[12]$|mobile|name.?on.?account|postal.?code|^zip|zip.?code)/i; +const CAMEL_EIN = /[a-z](Ein|EIN)(Number)?$/; +// A record id (`emailId`, `bankAccountId`, `resendEmailId`) names a row, not the value it points +// at. +const RECORD_ID = /(Id|ID|Ids|IDs|_id|_ids)$/; +// ...except where the id is itself the identifier of a person. +const PERSON_ID = /(tax|taxpayer|national|government|passport|license|licence|ssn).?(id|ids)$/i; + +/** Pinned by scrub-contract.test.ts: the agent's layer-2 log scrub repeats these patterns. */ +export const LOG_REDACT_PATTERNS = describePatterns({ + SENSITIVE_KEY_PATTERN, + CAMEL_EIN, + PERSON_ID, + RECORD_ID, +}); + +export { resetAppKeys as resetSensitiveKeys } from "./app-keys.js"; + +export function isSensitiveKey(key: string): boolean { + if (isAppKey(key)) return true; + if (PERSON_ID.test(key)) return true; + if (RECORD_ID.test(key)) return false; + return SENSITIVE_KEY_PATTERN.test(key) || CAMEL_EIN.test(key); +} + +const MAX_DEPTH = 8; +const MAX_BREADTH = 1_000; +/** Total values visited per log line, so a deep and wide object cannot stall the process. */ +const MAX_NODES = 5_000; +const REDACTED = "[redacted]"; +const TRUNCATED = "[truncated]"; +const ERROR_KEYS = new Set(["err", "error", "e"]); + +interface WalkState { + nodes: number; + ancestors: WeakSet; +} + +function isOpaque(value: object): boolean { + return ( + value instanceof Map || + value instanceof Set || + value instanceof RegExp || + value instanceof ArrayBuffer + ); +} + +/** A date as JSON shows it, read with the built-in method so a subclass cannot change it. */ +function isoDate(value: Date): string | null { + return Number.isNaN(Date.prototype.getTime.call(value)) + ? null + : Date.prototype.toISOString.call(value); +} + +/** + * The JSON-facing form of an object: URL, byte view, date and toJSON values converted; `done` + * stops the walk. A done value is inert: a plain string or object, so no serialization hook of + * the caller's (an own toJSON on a Map, a Date subclass) runs after the sanitizer. A Map, Set or + * RegExp becomes `{}`, as JSON shows one without own properties. + */ +export function convert(value: object): { done: boolean; value: unknown } { + if (value instanceof URL) return { done: true, value: scrubMessage(stripQuery(value.href)) }; + if (ArrayBuffer.isView(value)) return { done: true, value: { bytes: value.byteLength } }; + if (value instanceof Date) return { done: true, value: isoDate(value) }; + if (isOpaque(value)) return { done: true, value: {} }; + const toJSON = (value as { toJSON?: unknown }).toJSON; + if (typeof toJSON === "function") return { done: false, value: toJSON.call(value) }; + return { done: false, value }; +} + +/** + * A log call's first argument or child bindings as JSON would see them, so a spread keeps what a + * toJSON hides hidden. Byte views become `{ bytes }`; a value that is not a plain record (an + * array, a date, a map's entries, what a toJSON returns) lands under `value`. + */ +export function plainFields(fields: object, nested = false): Record { + if (ArrayBuffer.isView(fields)) return { bytes: fields.byteLength }; + if (Array.isArray(fields)) return { value: fields }; + if (fields instanceof Date) return { value: isoDate(fields) }; + if (fields instanceof Map) return { value: Object.fromEntries(fields) }; + const { value } = convert(fields); + if (value === fields) return fields as Record; + // What a toJSON returns gets the same treatment once, so a returned date or map keeps its value. + if (typeof value === "object" && value !== null) { + return nested ? (value as Record) : plainFields(value, true); + } + return { value }; +} + +type Visit = (item: unknown, depth: number, state: WalkState) => unknown; + +/** Maps an array within the breadth cap, stopping at the first item past the node budget. */ +function walkItems(items: unknown[], depth: number, state: WalkState, visit: Visit): unknown[] { + const out: unknown[] = []; + for (const item of items) { + if (out.length >= MAX_BREADTH || state.nodes >= MAX_NODES) { + out.push(TRUNCATED); + break; + } + out.push(visit(item, depth + 1, state)); + } + return out; +} + +/** A value under err/error/e at any depth: serialized deny-by-default; an array is mapped. */ +function walkErrorValue(value: unknown, depth: number, state: WalkState): unknown { + if (++state.nodes > MAX_NODES) return TRUNCATED; + if (value === null || value === undefined) return value; + if (!Array.isArray(value)) return serializeError(value); + if (depth > MAX_DEPTH) return TRUNCATED; + return walkItems(value, depth, state, walkErrorValue); +} + +/** `base`, or `base#n` when that name is taken; the chosen name is then taken. */ +function freeKey(base: string, taken: Set): string { + let candidate = base; + for (let n = 1; taken.has(candidate); n++) candidate = `${base}#${n}`; + taken.add(candidate); + return candidate; +} + +/** + * An object's entries within the breadth cap, each with the key it is logged under. A key that the + * message scrub would change becomes `[redacted]`; every clean key is reserved first, so a caller key that + * looks like a marker is never overwritten and never overwrites. + */ +function keyedEntries(target: object): { + entries: [string, string, unknown][]; + taken: Set; +} { + const raw = Object.entries(target).slice(0, MAX_BREADTH + 1); + const clean = raw.map(([k]) => scrubMessage(k) === k); + const taken = new Set(raw.filter((_, i) => clean[i]).map(([k]) => k)); + const entries = raw.map(([k, v], i): [string, string, unknown] => [ + k, + clean[i] ? k : freeKey(REDACTED, taken), + v, + ]); + return { entries, taken }; +} + +function walk(value: unknown, depth: number, state: WalkState): unknown { + if (++state.nodes > MAX_NODES) return TRUNCATED; + if (typeof value === "string") return scrubMessage(value); + // JSON drops a function; returning it would let its own toJSON run after the sanitizer. + if (typeof value === "function") return undefined; + if (typeof value !== "object" || value === null) return value; + if (value instanceof Error) return serializeError(value); + const converted = convert(value); + if (converted.done) return converted.value; + const target = converted.value; + // What a toJSON returns is walked like any value: a returned date, map or toJSON is reduced too. + // A primitive result (a Decimal's string) costs no depth. + if (target !== value) { + if (typeof target !== "object" || target === null) return walk(target, depth, state); + return depth > MAX_DEPTH ? TRUNCATED : walk(target, depth + 1, state); + } + if (state.ancestors.has(target)) return "[circular]"; + if (depth > MAX_DEPTH) return TRUNCATED; + state.ancestors.add(target); + try { + if (Array.isArray(target)) return walkItems(target, depth, state, walk); + const out: Record = {}; + const { entries, taken } = keyedEntries(target); + let count = 0; + for (const [k, key, child] of entries) { + if (++count > MAX_BREADTH || state.nodes >= MAX_NODES) { + out[freeKey(TRUNCATED, taken)] = true; + break; + } + if (isSensitiveKey(k)) out[key] = REDACTED; + else if (ERROR_KEYS.has(k)) out[key] = walkErrorValue(child, depth + 1, state); + else { + // A dropped value (a function) leaves no key, as in JSON, so it cannot shadow a method. + const walked = walk(child, depth + 1, state); + if (walked !== undefined) out[key] = walked; + } + } + return out; + } finally { + state.ancestors.delete(target); + } +} + +/** + * A sanitized copy of a log value; the caller's value is never mutated. Fails closed. The log + * object, child bindings and every format argument take this one path: PII keys are redacted at + * any depth, every Error and every non-nullish value under err/error/e is serialized deny-by- + * default, and every string gets the message scrub (`scrubMessage`). + */ +export function sanitizeLogValue(value: unknown): unknown { + try { + return walk(value, 0, { nodes: 0, ancestors: new WeakSet() }); + } catch { + return { log_redaction_failed: true }; + } +} + +/** The log object or bindings, through sanitizeLogValue. */ +export function redactLogObject(obj: Record): Record { + return sanitizeLogValue(obj) as Record; +} diff --git a/src/log-safely.ts b/src/log-safely.ts new file mode 100644 index 0000000..da1b161 --- /dev/null +++ b/src/log-safely.ts @@ -0,0 +1,31 @@ +import type { DiagLogger } from "@opentelemetry/api"; +import { log as sharedLog } from "./shared-log.js"; + +export const log = sharedLog.child({ component: "telemetry" }); + +type Level = "trace" | "debug" | "info" | "warn" | "error"; + +/** Telemetry never breaks the app, so not even a failing logger may throw out of it. */ +export function logSafely(level: Level, fields: Record, message: string): void { + try { + log[level](fields, message); + } catch { + // nothing left to report to + } +} + +/** OTel's own diagnostics, through the redacting logger; only an Error argument is kept. */ +function diagMethod(level: Level, event: string) { + return (message: string, ...args: unknown[]): void => { + const err = args.find((arg) => arg instanceof Error); + logSafely(level, { event, ...(err !== undefined && { err }) }, message); + }; +} + +export const diagLogger: DiagLogger = { + error: diagMethod("error", "otel_diag"), + warn: diagMethod("warn", "otel_diag"), + info: diagMethod("info", "otel_diag"), + debug: diagMethod("debug", "otel_diag"), + verbose: diagMethod("trace", "otel_diag"), +}; diff --git a/src/log-scrub.ts b/src/log-scrub.ts new file mode 100644 index 0000000..70987ec --- /dev/null +++ b/src/log-scrub.ts @@ -0,0 +1,91 @@ +import { describePatterns } from "./scrub-descriptor.js"; +import { + HOST_PORT, + QUERY_START, + QUOTE_END, + scrubUrls, + USERINFO_QUOTE, + URL_END, + URL_START, + URL_TOKEN, + USERINFO_END, +} from "./url-credentials.js"; + +// Every pattern here can start a match only at a bounded set of positions (an `@`, the start of a +// run, a newline), so each scrub is linear in the input length. A pattern that may start anywhere +// in a long run rescans the run from every position and blocks the event loop for seconds. URL +// userinfo and query removal live in url-credentials.ts. +const SSN_SHAPE = /\b\d{3}-\d{2}-\d{4}\b/g; +// An email's domain, matched at its `@`. The TLD must be alphabetic, so a pnpm store path +// (`.pnpm/hono+node-server@1.19.5/...`) is not an email. +const EMAIL_DOMAIN = /@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}\b/g; +const EMAIL_LOCAL_CHAR = /[A-Za-z0-9._%+-]/; +// Eight or more digits standing alone: an account or card number. A UUID group or a prefixed +// provider id (`pi_12345678`) touches a letter, `_` or `-`, so it is not matched. +const LONG_DIGIT_RUN = /(? last && EMAIL_LOCAL_CHAR.test(text[start - 1]!)) start--; + if (start === m.index) { + EMAIL_DOMAIN.lastIndex = m.index + 1; + continue; + } + out += text.slice(last, start) + REDACTED; + last = m.index + m[0].length; + } + return out + text.slice(last); +} + +/** + * Replaces SSN and email shapes in free text, strips the userinfo, query and fragment from any URL + * it quotes and the query and fragment from an absolute path. Phone and account shapes are left + * alone (id false positives). + */ +export function scrubMessage(text: string): string { + return replaceEmails(scrubUrls(text).replace(PATH_IN_TEXT, "$1$2").replace(SSN_SHAPE, REDACTED)); +} + +/** Error text comes from libraries and providers, so standalone long digit runs go too. */ +export function scrubErrorText(text: string): string { + return scrubMessage(text).replace(LONG_DIGIT_RUN, REDACTED); +} + +/** Scrubs the header line(s) of a stack and keeps every `at` frame byte for byte. */ +export function scrubStack(stack: string, header?: string): string { + const match = FRAME_START.exec(stack); + const at = match ? match.index + match[0].indexOf("\n") : -1; + const frames = at === -1 ? "" : stack.slice(at); + const head = header ?? (at === -1 ? stack : stack.slice(0, at)); + return `${scrubErrorText(head)}${frames}`; +} diff --git a/src/log-serializer.ts b/src/log-serializer.ts new file mode 100644 index 0000000..2847091 --- /dev/null +++ b/src/log-serializer.ts @@ -0,0 +1,18 @@ +import { serializeErrorBase } from "./log-errors.js"; +import { scrubErrorText } from "./log-scrub.js"; + +/** The `err` serializer: an allowlisted, scrubbed copy of the error chain. Never throws. */ +export function serializeError(value: unknown): unknown { + try { + return serializeErrorBase(value); + } catch { + let type = "Error"; + try { + const name = (value as { name?: unknown } | null)?.name; + if (typeof name === "string") type = scrubErrorText(name); + } catch { + // keep the default type + } + return { type, message: "[unserializable error]" }; + } +} diff --git a/src/log-sink.ts b/src/log-sink.ts new file mode 100644 index 0000000..6e2dfe9 --- /dev/null +++ b/src/log-sink.ts @@ -0,0 +1,32 @@ +/** Receives each serialized line the logger writes; installed only once the SDK has booted. */ +export type LogLineSink = (line: string) => void; + +let sink: LogLineSink | undefined; +let forwarding = false; + +/** Installs (or, with undefined, removes) the sink every logger line is copied to. */ +export function setLogLineSink(next: LogLineSink | undefined): void { + sink = next; +} + +export function hasLogLineSink(): boolean { + return sink !== undefined; +} + +/** + * Copies a written line to the sink and returns it unchanged. A sink that throws, or a line logged + * while the sink is running, is dropped: exporting must never break or recurse into a log call. + */ +export function forwardLogLine(line: string): string { + const current = sink; + if (current === undefined || forwarding) return line; + forwarding = true; + try { + current(line); + } catch { + // the line still reaches stdout + } finally { + forwarding = false; + } + return line; +} diff --git a/src/logger.ts b/src/logger.ts new file mode 100644 index 0000000..b7772e3 --- /dev/null +++ b/src/logger.ts @@ -0,0 +1,111 @@ +import { pino, stdTimeFunctions, type DestinationStream, type Logger } from "pino"; +import { sanitizeLogArgs } from "./log-message.js"; +import { plainFields, redactLogObject } from "./log-redact.js"; +import { traceLogFields } from "./log-fields.js"; +import { forwardLogLine } from "./log-sink.js"; + +// `silent` is not offered: it would hide fatal lines too. +const VALID_LEVELS = new Set(["trace", "debug", "info", "warn", "error", "fatal"]); +/** + * Fields the logger owns. A caller's field of the same name is renamed `field.`, so only the + * active span sets `trace_id`/`span_id` (a Stripe payout carries its own `trace_id`). + */ +const RESERVED_FIELDS = new Set(["level", "time", "msg", "app", "env", "trace_id", "span_id"]); + +/** + * LOG_LEVEL (any case) when it names a level; anything else falls back to "info" rather than + * throwing. + */ +export function resolveLogLevel(raw = process.env.LOG_LEVEL): { level: string; invalid?: string } { + const requested = raw?.trim(); + if (!requested) return { level: "info" }; + const level = requested.toLowerCase(); + return VALID_LEVELS.has(level) ? { level } : { level: "info", invalid: requested }; +} + +function renameReserved(fields: Record): Record { + if (!Object.keys(fields).some((key) => RESERVED_FIELDS.has(key))) return fields; + const out: Record = {}; + for (const [key, value] of Object.entries(fields)) { + out[RESERVED_FIELDS.has(key) ? `field.${key}` : key] = value; + } + return out; +} + +/** Child bindings in their JSON shape, renamed; a binding that cannot be read never throws. */ +function safeBindings(bindings: object): Record { + try { + return renameReserved(plainFields(bindings)); + } catch { + return { log_redaction_failed: true }; + } +} + +/** A new object: the caller's fields (reserved names renamed) under the logger's own fields. */ +function mergeMixin(fields: Record, mixin: object): Record { + try { + return { ...renameReserved(plainFields(fields)), ...mixin }; + } catch { + return { log_redaction_failed: true, ...mixin }; + } +} + +/** Emits the invalid LOG_LEVEL warning once; held back until the process has named itself. */ +const pendingLevelWarnings = new WeakMap void>(); + +export function announceLogLevel(logger: Logger): void { + const emit = pendingLevelWarnings.get(logger); + pendingLevelWarnings.delete(logger); + emit?.(); +} + +/** + * Builds a redacting pino logger that names its process in `app`. `app` is read on every line, so + * a logger or child created before the process names itself still reports the configured name. + */ +export function createLogger(app: () => string, destination?: DestinationStream): Logger { + const { level, invalid } = resolveLogLevel(); + let logger: Logger; + const announce = () => announceLogLevel(logger); + logger = pino( + { + level, + base: { env: process.env.RAILWAY_ENVIRONMENT_NAME?.trim() || "unknown" }, + // The log formatter sanitizes the whole object, err/error/e included, on the same path as + // format args. pino's default err serializer would rewrite that result, so it is replaced. + serializers: { err: (value: unknown) => value }, + formatters: { + level: (label) => ({ level: label }), + bindings: redactLogObject, + log: redactLogObject, + }, + hooks: { + logMethod(args, method) { + announce(); + return method.apply(this, sanitizeLogArgs(args) as Parameters); + }, + // The finished, scrubbed line: written unchanged, and copied to the OTLP sink once the SDK + // has installed one. + streamWrite: forwardLogLine, + }, + timestamp: stdTimeFunctions.isoTime, + mixin: () => ({ app: app(), ...traceLogFields() }), + mixinMergeStrategy: (fields, mixin) => mergeMixin(fields as Record, mixin), + }, + destination, + ); + // pino applies formatters.bindings to the base bindings only, so child bindings are handled here. + const originalChild = logger.child; + logger.child = function (this: Logger, bindings, options) { + return originalChild.call(this, redactLogObject(safeBindings(bindings)), options); + } as Logger["child"]; + if (invalid !== undefined) { + pendingLevelWarnings.set(logger, () => + logger.warn( + { event: "invalid_log_level", requested: invalid }, + "invalid LOG_LEVEL, using info", + ), + ); + } + return logger; +} diff --git a/src/meter.ts b/src/meter.ts new file mode 100644 index 0000000..744360b --- /dev/null +++ b/src/meter.ts @@ -0,0 +1,11 @@ +import { metrics, type Meter } from "@opentelemetry/api"; + +/** + * A meter on the global provider. Obtain it after `startTelemetry`: a meter taken earlier is a + * no-op and never records, even once telemetry starts. + */ +export function getMeter(name: string): Meter { + return metrics.getMeter(name); +} + +export type { BatchObservableResult, Meter } from "@opentelemetry/api"; diff --git a/src/metric-views.ts b/src/metric-views.ts new file mode 100644 index 0000000..a0e309f --- /dev/null +++ b/src/metric-views.ts @@ -0,0 +1,16 @@ +import { createAllowListAttributesProcessor, type ViewOptions } from "@opentelemetry/sdk-metrics"; +import type { MetricViewOptions } from "./options.js"; + +/** + * One view that drops every attribute outside `allowedAttributes` from the app's own instruments + * (`*`), so an id or a message never becomes a label. No options, no views. + */ +export function metricViews(options?: MetricViewOptions): ViewOptions[] { + if (!options) return []; + return [ + { + instrumentName: `${options.instrumentPrefix}*`, + attributesProcessors: [createAllowListAttributesProcessor([...options.allowedAttributes])], + }, + ]; +} diff --git a/src/options.ts b/src/options.ts new file mode 100644 index 0000000..0b3c742 --- /dev/null +++ b/src/options.ts @@ -0,0 +1,124 @@ +import type { Instrumentation } from "@opentelemetry/instrumentation"; +import { addAppKeys } from "./app-keys.js"; +import type { TelemetryEnv } from "./config.js"; + +/** An allowlist view: instruments named `*` export only these attributes. */ +export interface MetricViewOptions { + instrumentPrefix: string; + allowedAttributes: readonly string[]; +} + +/** Context passed to `onError`: the service name, plus whatever the caller adds. */ +export type ErrorHookContext = Record; + +export interface TelemetryOptions { + serviceName: string; + /** Only records from this logger scope are exported; defaults to `serviceName`. */ + logScope?: string; + /** Without it, every metric attribute is kept. */ + metricViews?: MetricViewOptions; + /** Appended to the package's own list, so Prisma and the like match the app's versions. */ + instrumentations?: Instrumentation[]; + /** Log field names redacted on top of the default PII key list; never removes a default. */ + redactKeys?: readonly string[]; + /** Receives every fatal error `installFatalHandlers` sees, when it is given no `report`. */ + onError?: (err: unknown, context: ErrorHookContext) => void; + /** Read instead of `process.env`. */ + env?: TelemetryEnv; + /** Incoming request paths that open no server span; each covers itself and everything below. */ + ignoredPaths?: readonly string[]; + /** False for a host that runs bundled CommonJS (Next.js): no ESM loader hook is registered. */ + esmLoaderHook?: boolean; + /** False keeps log lines on stdout only; a host whose own code does not use the shared logger. */ + logs?: boolean; +} + +let scope: string | undefined; +let service: string | undefined; +let errorHook: TelemetryOptions["onError"]; + +/** A checked copy of the caller's options, and the types of the redact keys it dropped. */ +export interface CheckedOptions { + options: TelemetryOptions; + invalidKeyTypes: string[]; +} + +function check(ok: boolean, message: string): void { + if (!ok) throw new TypeError(message); +} + +/** + * Validates and copies options without touching any state. Throws on options that cannot be read; + * a redact key that is not a string is dropped and only its type is reported. + */ +export function checkOptions(input: TelemetryOptions): CheckedOptions { + check(typeof input === "object" && input !== null, "options must be an object"); + const options = { ...input }; + check(typeof options.serviceName === "string", "serviceName must be a string"); + check(options.logScope === undefined || typeof options.logScope === "string", "bad logScope"); + check(options.onError === undefined || typeof options.onError === "function", "bad onError"); + check(options.env === undefined || typeof options.env === "object", "bad env"); + check(options.redactKeys === undefined || Array.isArray(options.redactKeys), "bad redactKeys"); + const keys: unknown[] = [...(options.redactKeys ?? [])]; + const invalidKeyTypes = keys + .filter((key) => typeof key !== "string") + .map((key) => (key === null ? "null" : typeof key)); + if (options.redactKeys) { + options.redactKeys = keys.filter((key): key is string => typeof key === "string"); + } + return { options, invalidKeyTypes }; +} + +/** + * Applies checked options in one step: the log scope, the error hook and the redact keys. Nothing + * here can throw, so a call never leaves its options half-applied. + */ +export function applyOptions(options: TelemetryOptions): void { + scope = options.logScope?.trim() || options.serviceName; + service = options.serviceName; + errorHook = options.onError; + if (options.redactKeys) addAppKeys(options.redactKeys); +} + +/** The configured log scope; before `startTelemetry`, the service name from the environment. */ +export function logScope(): string { + return scope ?? (process.env.OTEL_SERVICE_NAME?.trim() || "unknown"); +} + +/** Calls the app's error hook, if any, with `service` set; never throws. */ +export function reportError(err: unknown, context: ErrorHookContext = {}): void { + try { + errorHook?.(err, { service: service ?? "unknown", ...context }); + } catch { + // reporting must never break the caller + } +} + +/** Equal arrays: the same items, by identity. */ +function sameItems(a: unknown, b: unknown): boolean { + if (!Array.isArray(a) || !Array.isArray(b)) return a === b; + return a.length === b.length && a.every((item, i) => item === b[i]); +} + +/** True when two calls pass the same options; `env` aside, since it only says whether to export. */ +export function sameOptions(a: TelemetryOptions, b: TelemetryOptions): boolean { + const keys = new Set([...Object.keys(a), ...Object.keys(b)]); + keys.delete("env"); + for (const key of keys) { + const x = a[key as keyof TelemetryOptions]; + const y = b[key as keyof TelemetryOptions]; + if (key === "metricViews") { + const [m, n] = [x as MetricViewOptions | undefined, y as MetricViewOptions | undefined]; + if (m?.instrumentPrefix !== n?.instrumentPrefix) return false; + if (!sameItems(m?.allowedAttributes, n?.allowedAttributes)) return false; + } else if (!sameItems(x, y)) return false; + } + return true; +} + +/** Test seam: forgets the recorded options. */ +export function resetOptions(): void { + scope = undefined; + service = undefined; + errorHook = undefined; +} diff --git a/src/profiling.ts b/src/profiling.ts new file mode 100644 index 0000000..9ae6671 --- /dev/null +++ b/src/profiling.ts @@ -0,0 +1,61 @@ +import type { PyroscopeConfig } from "@pyroscope/nodejs"; +import type { TelemetryConfig, TelemetryEnv } from "./config.js"; +import { logSafely } from "./log-safely.js"; + +/** The slice of the profiler used here; the CJS build surfaces it on `default` under ESM. */ +interface Pyroscope { + init(config?: PyroscopeConfig): void; + start(): void; + stop(): Promise; +} + +let profiler: Pyroscope | undefined; + +/** + * Starts Pyroscope CPU and heap profiling. Gated on `PYROSCOPE_SERVER_ADDRESS` alone, so it + * is independent of the OTLP endpoint, and `OTEL_SDK_DISABLED=true` still switches it off. + * Tags are `env` and `version` only; request-scoped labels would carry identifiers. Never + * rejects: a missing native binary logs `profiling_start_failed` and returns false. + */ +export async function startProfiling( + config: TelemetryConfig, + env: TelemetryEnv = process.env, +): Promise { + try { + if (profiler) return true; + const serverAddress = env.PYROSCOPE_SERVER_ADDRESS?.trim(); + if (!serverAddress) return false; + if (env.OTEL_SDK_DISABLED?.trim().toLowerCase() === "true") return false; + const module = await import("@pyroscope/nodejs"); + const pyroscope: Pyroscope = + (module as unknown as { default?: Pyroscope }).default ?? (module as Pyroscope); + pyroscope.init({ + serverAddress, + appName: config.serviceName, + tags: { env: config.environment, version: config.serviceVersion }, + // Pins the 0.6.4 defaults: 60 s flush, 100 Hz wall sampling, one heap sample per 512 KiB. + flushIntervalMs: 60_000, + wall: { samplingDurationMs: 60_000, samplingIntervalMicros: 10_000 }, + heap: { samplingIntervalBytes: 512 * 1024, stackDepth: 64 }, + }); + pyroscope.start(); + profiler = pyroscope; + return true; + } catch (error) { + profiler = undefined; + logSafely("warn", { err: error, event: "profiling_start_failed" }, "profiling_start_failed"); + return false; + } +} + +/** Stops the profiler; never rejects. */ +export async function stopProfiling(): Promise { + const running = profiler; + profiler = undefined; + if (!running) return; + try { + await running.stop(); + } catch { + // shutdown proceeds regardless + } +} diff --git a/src/redact.ts b/src/redact.ts new file mode 100644 index 0000000..7c6ac9b --- /dev/null +++ b/src/redact.ts @@ -0,0 +1,141 @@ +import type { Attributes } from "@opentelemetry/api"; +import { isAppAttributeKey } from "./app-keys.js"; +import { scrubMessage } from "./log-scrub.js"; +import { describeKeySets, describePatterns } from "./scrub-descriptor.js"; +import { stripUserinfo, URL_AUTHORITY } from "./url-credentials.js"; +import { routeTemplateOf, templateUrlPath, TOKEN_PATH_TEMPLATES } from "./url-path.js"; + +const URL_KEYS = new Set(["url.full", "url.original", "http.url", "http.target", "url.path"]); +const DROPPED_KEYS = new Set([ + "url.query", + "http.request.body", + "http.response.body", + "client.address", + "network.peer.address", + "bullmq.job.result", + "bullmq.job.progress", +]); +/** Kept so a failure is visible, but its content is the thrown message. */ +const REDACTED_KEYS = new Set(["exception.message", "bullmq.job.failed.reason"]); +const DROPPED_PREFIXES = [ + "http.request.header.", + "http.response.header.", + "messaging.message.body", + "db.query.parameter.", + "db.operation.parameter.", + "enduser.", + "user.", +]; +const STATEMENT_KEYS = ["db.statement", "db.query.text"]; +// A span with either key set to this is a Redis call, whose statement quotes the values it sent. +const REDIS_SYSTEM_KEYS = ["db.system.name", "db.system"]; +const REDIS_SYSTEM = "redis"; +const URL_QUERY_START = /[?#;]/; +const FRAME_LINE = /^\s+at /; + +/** `schemeless` reads a value with no scheme and no leading `/` as starting at its authority. */ +export function stripQuery(url: string, schemeless = false): string { + // Userinfo first: a password can hold a `?` or `#`. + const withoutUserinfo = stripUserinfo(url, schemeless); + const cut = withoutUserinfo.search(URL_QUERY_START); + return cut === -1 ? withoutUserinfo : withoutUserinfo.slice(0, cut); +} + +// `METHOD /path`, `METHOD scheme://host/path` or `METHOD user@host/path`. +const NAMED_REQUEST = /^([A-Z]+) ((?:\/|[a-z][a-z0-9+.-]*:\/\/|[^\s/?#@]*@)\S*)$/i; +const NAME_KEYS = new Set(["next.span_name"]); +const WORD = /\S+/g; + +/** Pinned by scrub-contract.test.ts: the agent's layer-2 span scrub repeats these. */ +export const REDACT_PATTERNS = describePatterns({ + URL_AUTHORITY, + URL_QUERY_START, + NAMED_REQUEST, + FRAME_LINE, +}); +export const REDACT_KEY_SETS = describeKeySets({ + URL_KEYS, + DROPPED_KEYS, + REDACTED_KEYS, + DROPPED_PREFIXES, + STATEMENT_KEYS, + NAME_KEYS, + REDIS_SYSTEM_KEYS, +}); +export const REDACT_LITERALS = Object.freeze({ REDIS_SYSTEM }); + +/** + * A span name of the form `METHOD /path`, `METHOD scheme://host/path` or `METHOD user@host/path` + * gets the same treatment as a URL attribute: no query, fragment or userinfo, and the path + * templated. Next.js names an unrouted request's span after its raw URL. Any other name gets the + * message scrub, and each of its words its token paths templated. + */ +export function scrubSpanName( + name: string, + route: string | undefined, + tokenPaths: readonly (readonly [RegExp, string])[] = TOKEN_PATH_TEMPLATES, +): string { + const match = NAMED_REQUEST.exec(name); + if (!match) { + return scrubMessage(name).replace(WORD, (word) => templateUrlPath(word, undefined, tokenPaths)); + } + return `${match[1]} ${templateUrlPath(stripQuery(match[2]!, true), route, tokenPaths)}`; +} + +function isRedis(attributes: Attributes): boolean { + return REDIS_SYSTEM_KEYS.some((key) => attributes[key] === REDIS_SYSTEM); +} + +function scrubExceptionStacktrace(stacktrace: string, exceptionType?: string): string { + const lines = stacktrace.split("\n"); + const type = exceptionType || "Error"; + let frameStartIndex = lines.findIndex((line) => FRAME_LINE.test(line)); + if (frameStartIndex === -1) { + return `${type}: [redacted]`; + } + const result = [`${type}: [redacted]`, ...lines.slice(frameStartIndex)]; + return result.join("\n"); +} + +/** + * Scrubs one attribute set. Every URL attribute loses its query, fragment and userinfo, and its + * path becomes the span's `http.route` template when it has one, so a path parameter never + * leaves. Without a route (client spans, unmatched requests) `url.path` is dropped and known + * token-bearing paths are templated in the remaining URL attributes. An attribute named by the + * app's `redactKeys` (its whole name or last `.` segment) is redacted. + */ +export function scrubAttributes( + attributes: Attributes, + tokenPaths: readonly (readonly [RegExp, string])[] = TOKEN_PATH_TEMPLATES, +): Attributes { + const redis = isRedis(attributes); + const route = routeTemplateOf(attributes["http.route"]); + const scrubbed: Attributes = {}; + for (const [key, value] of Object.entries(attributes)) { + if (DROPPED_KEYS.has(key) || DROPPED_PREFIXES.some((prefix) => key.startsWith(prefix))) + continue; + if (redis && STATEMENT_KEYS.includes(key)) continue; + if (key === "url.path" && !route) continue; + + if (REDACTED_KEYS.has(key) || isAppAttributeKey(key)) { + scrubbed[key] = "[redacted]"; + } else if (key === "exception.stacktrace") { + if (typeof value === "string") { + const exceptionType = attributes["exception.type"]; + scrubbed[key] = scrubExceptionStacktrace(value, exceptionType as string | undefined); + } else { + scrubbed[key] = "[redacted]"; + } + } else { + scrubbed[key] = + typeof value !== "string" + ? value + : URL_KEYS.has(key) + ? templateUrlPath(stripQuery(value, true), route, tokenPaths) + : NAME_KEYS.has(key) + ? scrubSpanName(value, route, tokenPaths) + : value; + } + } + return scrubbed; +} diff --git a/src/redacting-exporter.ts b/src/redacting-exporter.ts new file mode 100644 index 0000000..7035d66 --- /dev/null +++ b/src/redacting-exporter.ts @@ -0,0 +1,61 @@ +import type { ExportResult } from "@opentelemetry/core"; +import type { ReadableSpan, SpanExporter } from "@opentelemetry/sdk-trace-base"; +import { scrubAttributes, scrubSpanName } from "./redact.js"; +import { routeTemplateOf, TOKEN_PATH_TEMPLATES } from "./url-path.js"; + +type TokenPaths = readonly (readonly [RegExp, string])[]; + +// The span status field that carries the thrown message; it is kept, redacted, so a failure shows. +const REDACTED_STATUS_KEY = "message"; + +/** Pinned by scrub-contract.test.ts: the agent's layer-2 span scrub repeats it. */ +export const EXPORTER_LITERALS = Object.freeze({ REDACTED_STATUS_KEY }); + +function scrubSpan(span: ReadableSpan, tokenPaths: TokenPaths): ReadableSpan { + const attributes = scrubAttributes(span.attributes, tokenPaths); + const name = scrubSpanName(span.name, routeTemplateOf(span.attributes["http.route"]), tokenPaths); + const events = span.events.map((event) => + event.attributes + ? { ...event, attributes: scrubAttributes(event.attributes, tokenPaths) } + : event, + ); + const links = span.links.map((link) => + link.attributes ? { ...link, attributes: scrubAttributes(link.attributes, tokenPaths) } : link, + ); + const status = + span.status && span.status[REDACTED_STATUS_KEY] + ? { ...span.status, [REDACTED_STATUS_KEY]: "[redacted]" } + : span.status; + + return new Proxy(span, { + get(target, property) { + if (property === "name") return name; + if (property === "attributes") return attributes; + if (property === "events") return events; + if (property === "links") return links; + if (property === "status") return status; + const value: unknown = Reflect.get(target, property, target); + return typeof value === "function" ? value.bind(target) : value; + }, + }); +} + +export class RedactingSpanExporter implements SpanExporter { + /** `tokenPaths` are the path patterns templated on spans without a route template. */ + constructor( + private readonly inner: SpanExporter, + private readonly tokenPaths: TokenPaths = TOKEN_PATH_TEMPLATES, + ) {} + export(spans: ReadableSpan[], done: (result: ExportResult) => void): void { + this.inner.export( + spans.map((span) => scrubSpan(span, this.tokenPaths)), + done, + ); + } + shutdown(): Promise { + return this.inner.shutdown(); + } + forceFlush(): Promise { + return this.inner.forceFlush?.() ?? Promise.resolve(); + } +} diff --git a/src/register.ts b/src/register.ts new file mode 100644 index 0000000..3048de0 --- /dev/null +++ b/src/register.ts @@ -0,0 +1,7 @@ +// `node --import @codelittinc/otel-node/register`: starts telemetry before the app loads, for an +// app with no options to pass. `OTEL_SERVICE_NAME` names the service and is its log scope. +import { startTelemetry } from "./sdk.js"; + +const serviceName = process.env.OTEL_SERVICE_NAME?.trim() || "unknown"; + +await startTelemetry({ serviceName, logScope: serviceName }); diff --git a/src/scrub-contract.json b/src/scrub-contract.json new file mode 100644 index 0000000..b424515 --- /dev/null +++ b/src/scrub-contract.json @@ -0,0 +1,128 @@ +{ + "SENSITIVE_KEY_PATTERN": { + "source": "(^ssn|ssn$|ssn.?(enc|last)|_ssn|social.?security|^dob|dob$|dob.?enc|_dob|date.?of.?birth|birth.?date|account.?number|routing.?number|tax.?id|taxpayer|(^|_)ein(_?number)?$|e.?mail|phone|password|passwd|secret|token|api.?key|authorization|cookie|holder.?name|iban|passport|drivers?.?license|birthday|bank.?account|aba.?number|clerk.?auth|signature$|enc$|enc.?snapshot$|(first|last|middle|full|legal|preferred|given|family).?name|surname|(occupant|caller|contact|payee|bill.?to|tenant|resident|applicant|guarantor|performed.?by|assignee|recipient|signer).?name|(street|mailing|home|billing|residential|postal).?address|^address(.?line)?.?\\d*$|address.?line|address$|forwarding.?address|^street\\d*$|^line[12]$|mobile|name.?on.?account|postal.?code|^zip|zip.?code)", + "flags": "i" + }, + "CAMEL_EIN": { + "source": "[a-z](Ein|EIN)(Number)?$", + "flags": "" + }, + "PERSON_ID": { + "source": "(tax|taxpayer|national|government|passport|license|licence|ssn).?(id|ids)$", + "flags": "i" + }, + "RECORD_ID": { + "source": "(Id|ID|Ids|IDs|_id|_ids)$", + "flags": "" + }, + "SSN_SHAPE": { + "source": "\\b\\d{3}-\\d{2}-\\d{4}\\b", + "flags": "g" + }, + "EMAIL_DOMAIN": { + "source": "@[A-Za-z0-9-]+(?:\\.[A-Za-z0-9-]+)*\\.[A-Za-z]{2,}\\b", + "flags": "g" + }, + "EMAIL_LOCAL_CHAR": { + "source": "[A-Za-z0-9._%+-]", + "flags": "" + }, + "LONG_DIGIT_RUN": { + "source": "(?]", + "flags": "" + }, + "URL_END": { + "source": "[\\s`<>]", + "flags": "" + }, + "QUOTE_END": { + "source": "[\"'](?=$|[\\s,;:}\\])>\"'&])", + "flags": "y" + }, + "USERINFO_QUOTE": { + "source": "'[)']", + "flags": "y" + }, + "HOST_PORT": { + "source": "^(?:\\[[^\\]]*\\]|[^:[\\]]+)(?::\\d*)?$", + "flags": "" + }, + "QUERY_START": { + "source": "[?#;]", + "flags": "" + }, + "PATH_IN_TEXT": { + "source": "(^|\\s)(\\/[^\\s?#;]*)[?#;]\\S*", + "flags": "g" + }, + "URL_AUTHORITY": { + "source": "^\\s*(?:(?:[0-9+.-]*[a-z][a-z0-9+.-]*:)+(?:\\/\\/?|\\\\\\/\\\\\\/)|\\/\\/)", + "flags": "i" + }, + "URL_QUERY_START": { + "source": "[?#;]", + "flags": "" + }, + "NAMED_REQUEST": { + "source": "^([A-Z]+) ((?:\\/|[a-z][a-z0-9+.-]*:\\/\\/|[^\\s/?#@]*@)\\S*)$", + "flags": "i" + }, + "FRAME_LINE": { + "source": "^\\s+at ", + "flags": "" + }, + "ORIGIN": { + "source": "^((?:[a-z][a-z0-9+.-]*:)*\\/\\/[^/?#]*)(.*)$", + "flags": "is" + }, + "TOKEN_PATH_TEMPLATES": [ + { + "source": "^\\/+v1\\/+applications\\/+by-token\\/+[^/?#]+", + "flags": "i", + "replacement": "/v1/applications/by-token/:token" + }, + { + "source": "^\\/+apply\\/+[^/?#]+", + "flags": "i", + "replacement": "/apply/[token]" + } + ], + "URL_KEYS": ["http.target", "http.url", "url.full", "url.original", "url.path"], + "DROPPED_KEYS": [ + "bullmq.job.progress", + "bullmq.job.result", + "client.address", + "http.request.body", + "http.response.body", + "network.peer.address", + "url.query" + ], + "REDACTED_KEYS": ["bullmq.job.failed.reason", "exception.message"], + "DROPPED_PREFIXES": [ + "db.operation.parameter.", + "db.query.parameter.", + "enduser.", + "http.request.header.", + "http.response.header.", + "messaging.message.body", + "user." + ], + "STATEMENT_KEYS": ["db.query.text", "db.statement"], + "NAME_KEYS": ["next.span_name"], + "REDIS_SYSTEM_KEYS": ["db.system", "db.system.name"], + "REDIS_SYSTEM": "redis", + "ROUTE_TEMPLATE_PREFIX": "/", + "REDACTED_STATUS_KEY": "message" +} diff --git a/src/scrub-descriptor.ts b/src/scrub-descriptor.ts new file mode 100644 index 0000000..e0ba2cb --- /dev/null +++ b/src/scrub-descriptor.ts @@ -0,0 +1,24 @@ +/** A regex's source and flags, for the scrub contract test; never the stateful RegExp itself. */ +export interface PatternDescriptor { + readonly source: string; + readonly flags: string; +} + +export function describePatterns( + patterns: Record, +): Readonly> { + const out = {} as Record; + for (const name of Object.keys(patterns) as K[]) { + const { source, flags } = patterns[name]; + out[name] = Object.freeze({ source, flags }); + } + return Object.freeze(out); +} + +export function describeKeySets( + sets: Record>, +): Readonly> { + const out = {} as Record; + for (const name of Object.keys(sets) as K[]) out[name] = Object.freeze([...sets[name]].sort()); + return Object.freeze(out); +} diff --git a/src/sdk-boot.ts b/src/sdk-boot.ts new file mode 100644 index 0000000..28db98c --- /dev/null +++ b/src/sdk-boot.ts @@ -0,0 +1,147 @@ +import { register } from "node:module"; +import { createAddHookMessageChannel } from "import-in-the-middle"; +import { NodeSDK } from "@opentelemetry/sdk-node"; +import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto"; +import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-proto"; +import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics"; +import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base"; +import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-proto"; +import { BatchLogRecordProcessor } from "@opentelemetry/sdk-logs"; +import { logs } from "@opentelemetry/api-logs"; +import { resourceFromAttributes } from "@opentelemetry/resources"; +import { + ATTR_SERVICE_INSTANCE_ID, + ATTR_SERVICE_NAME, + ATTR_SERVICE_VERSION, +} from "@opentelemetry/semantic-conventions"; +import { BullMQOtel } from "bullmq-otel"; +import type { Telemetry } from "bullmq"; +import type { Instrumentation } from "@opentelemetry/instrumentation"; +import type { TelemetryConfig } from "./config.js"; +import { buildInstrumentations } from "./instrumentations.js"; +import { metricViews } from "./metric-views.js"; +import type { MetricViewOptions } from "./options.js"; +import { RedactingSpanExporter } from "./redacting-exporter.js"; +import { logSafely } from "./log-safely.js"; +import { setLogLineSink } from "./log-sink.js"; +import { logLineSink, ScopedLogRecordProcessor } from "./log-otlp.js"; + +/** Longest boot waits for the loader thread to acknowledge the hook registration. */ +const HOOK_ACK_TIMEOUT_MS = 5_000; + +/** + * Log records per OTLP request. Alloy refuses a whole request over 32 MiB + * (`agent/config/base.alloy` in codelittinc/carbon-observability), and the logger does not cap a + * line, so a batch is bounded by count: 64 records stay under the limit unless they average over + * 512 KiB each. The SDK default of 512 would fail at 64 KiB each. + */ +const LOG_EXPORT_BATCH_SIZE = 64; + +export interface BootedSdk { + shutdown(): Promise; + bullmq: Telemetry; +} + +export interface BootOptions { + logScope: string; + ignoredPaths?: readonly string[]; + esmLoaderHook?: boolean; + exportLogs?: boolean; + metricViews?: MetricViewOptions; + instrumentations?: readonly Instrumentation[]; +} + +/** + * Registers the ESM loader hook (unless `esmLoaderHook` is false) and starts the Node SDK. Loaded + * only once telemetry is enabled, so a process without an endpoint never pays for the SDK and its + * instrumentations. With `exportLogs`, every line the shared logger writes is also sent over OTLP + * under `logScope`. + */ +export async function bootSdk(config: TelemetryConfig, options: BootOptions): Promise { + const { logScope, esmLoaderHook = true, exportLogs = true } = options; + // A message channel limits import-in-the-middle to modules the instrumentations register, + // instead of wrapping every ESM module the app loads. + const hook = esmLoaderHook ? createAddHookMessageChannel() : undefined; + if (hook) register("import-in-the-middle/hook.mjs", import.meta.url, hook.registerOptions); + const sdk = new NodeSDK({ + resource: resourceFromAttributes({ + [ATTR_SERVICE_NAME]: config.serviceName, + [ATTR_SERVICE_VERSION]: config.serviceVersion, + [ATTR_SERVICE_INSTANCE_ID]: config.serviceInstanceId, + "deployment.environment.name": config.environment, + }), + spanProcessors: [new BatchSpanProcessor(new RedactingSpanExporter(new OTLPTraceExporter()))], + metricReaders: [ + new PeriodicExportingMetricReader({ + exporter: new OTLPMetricExporter(), + exportIntervalMillis: 60_000, + }), + ], + views: metricViews(options.metricViews), + // Never omitted: an omitted list makes NodeSDK build its own log exporter from env. Records + // come only from the logger's scrubbed output (see log-otlp.ts), never from an + // instrumentation that sees a log call before it is scrubbed. + logRecordProcessors: exportLogs + ? [ + new ScopedLogRecordProcessor( + new BatchLogRecordProcessor({ + exporter: new OTLPLogExporter(), + maxExportBatchSize: LOG_EXPORT_BATCH_SIZE, + }), + logScope, + ), + ] + : [], + instrumentations: buildInstrumentations(options.ignoredPaths ?? [], options.instrumentations), + }); + sdk.start(); + const booted = { + shutdown: () => { + setLogLineSink(undefined); + // After the records the sink has already queued for emission. + return Promise.resolve().then(() => sdk.shutdown()); + }, + bullmq: new BullMQOtel({ tracerName: logScope }), + }; + if (exportLogs) setLogLineSink(logLineSink(logs.getLogger(logScope))); + if (hook) await acknowledgedWithin(hook.waitForAllMessagesAcknowledged, HOOK_ACK_TIMEOUT_MS); + return booted; +} + +/** + * Abandons a loader acknowledgement that never arrives, so boot cannot hang. The wait keeps a + * referenced interval alive until the ack lands; on timeout it is unreferenced, so an abandoned + * ack never holds the process open. + */ +async function acknowledgedWithin(wait: () => Promise, timeoutMs: number): Promise { + const { result: ack, intervals } = captureIntervals(wait); + let timer: NodeJS.Timeout | undefined; + const timeout = new Promise<"timeout">((resolve) => { + timer = setTimeout(() => resolve("timeout"), timeoutMs); + timer.unref(); + }); + try { + if ((await Promise.race([ack, timeout])) === "timeout") { + for (const interval of intervals) interval.unref(); + logSafely("warn", { event: "telemetry_hook_ack_timeout" }, "telemetry_hook_ack_timeout"); + } + } finally { + clearTimeout(timer); + } +} + +/** Runs `fn` and returns the intervals it created synchronously. */ +function captureIntervals(fn: () => T): { result: T; intervals: NodeJS.Timeout[] } { + const intervals: NodeJS.Timeout[] = []; + const original = globalThis.setInterval; + globalThis.setInterval = ((...args: Parameters) => { + const interval = original(...args); + intervals.push(interval); + return interval; + }) as typeof setInterval; + try { + return { result: fn(), intervals }; + } finally { + globalThis.setInterval = original; + } +} diff --git a/src/sdk.ts b/src/sdk.ts new file mode 100644 index 0000000..421a19d --- /dev/null +++ b/src/sdk.ts @@ -0,0 +1,146 @@ +import { diag, DiagLogLevel } from "@opentelemetry/api"; +import { resolveTelemetryConfig, type TelemetryConfig, type TelemetryEnv } from "./config.js"; +import type { BootedSdk } from "./sdk-boot.js"; +import { diagLogger, logSafely } from "./log-safely.js"; +import { + applyOptions, + checkOptions, + logScope, + sameOptions, + type TelemetryOptions, +} from "./options.js"; +import { startProfiling, stopProfiling } from "./profiling.js"; + +let sdk: BootedSdk | undefined; +let started: TelemetryConfig | undefined; +let applied: TelemetryOptions | undefined; + +/** + * Boots the OpenTelemetry Node SDK. Never rejects: a failed start is logged and the config + * is returned. Telemetry starts once per process; a restart after `shutdownTelemetry` is not + * supported (shutdown resets state only so tests can run in sequence). The ESM loader hook is + * registered here, so this module must run under `node --import` before the app loads `http`. + * The SDK itself is imported only once telemetry is enabled. A host that runs bundled CommonJS + * (Next.js) passes `esmLoaderHook: false`. The log scope, `redactKeys` and `onError` apply even + * when export is off, since they shape stdout and error reporting too. The first call's options + * win whether or not it exported; a later call may only turn export on through its `env`, and + * warns `telemetry_restart_ignored` when its options differ. + */ +export async function startTelemetry(options: TelemetryOptions): Promise { + let effective: TelemetryOptions; + let env: TelemetryEnv; + let config: TelemetryConfig; + try { + effective = resolveOptions(options); + if (started) return started; + env = effective.env ?? process.env; + config = resolveTelemetryConfig(effective.serviceName, env); + } catch (error) { + logSafely("warn", { err: error, event: "telemetry_start_failed" }, "telemetry_start_failed"); + if (started) return started; + const name = typeof options?.serviceName === "string" ? options.serviceName : "unknown"; + return { ...resolveTelemetryConfig(name, {}), enabled: false }; + } + if (!config.enabled) { + await startProfiling(config, env); + return config; + } + started = config; + installDiagLogger(env.OTEL_LOG_LEVEL); + try { + const { bootSdk } = await import("./sdk-boot.js"); + sdk = await bootSdk(config, { + logScope: logScope(), + ignoredPaths: effective.ignoredPaths ?? [], + esmLoaderHook: effective.esmLoaderHook !== false, + exportLogs: effective.logs !== false, + metricViews: effective.metricViews, + instrumentations: effective.instrumentations, + }); + logSafely( + "info", + { + event: "telemetry_started", + serviceName: config.serviceName, + endpointHost: hostOf(env.OTEL_EXPORTER_OTLP_ENDPOINT), + }, + "telemetry_started", + ); + } catch (error) { + sdk = undefined; + logSafely("warn", { err: error, event: "telemetry_start_failed" }, "telemetry_start_failed"); + } + await startProfiling(config, env); + return config; +} + +/** The first call's options, applied once; a later call keeps them and only brings its `env`. */ +function resolveOptions(input: TelemetryOptions): TelemetryOptions { + const { options, invalidKeyTypes } = checkOptions(input); + if (applied) { + if (!sameOptions(applied, options)) { + logSafely("warn", { event: "telemetry_restart_ignored" }, "telemetry_restart_ignored"); + } + return { ...applied, env: options.env }; + } + if (invalidKeyTypes.length > 0) { + logSafely("warn", { event: "invalid_redact_key", types: invalidKeyTypes }, "invalid_redact_key"); + } + applyOptions(options); + applied = options; + return options; +} + +/** True once `startTelemetry` has booted the SDK in this process. */ +export function isTelemetryStarted(): boolean { + return sdk !== undefined; +} + +/** + * The BullMQ adapter built by `startTelemetry`; undefined until telemetry has started. Typed + * `unknown` so this module's types never name bullmq; `./bullmq` gives it its type. + */ +export function startedBullmqTelemetry(): unknown { + return sdk?.bullmq; +} + +/** + * Flushes pending spans, metrics, log records and profiles, bounded so a dead collector cannot + * stall a shutdown. + */ +export async function shutdownTelemetry(timeoutMs = 5_000): Promise { + started = undefined; + applied = undefined; + const running = sdk; + sdk = undefined; + const timeout = new Promise((resolve) => setTimeout(resolve, timeoutMs).unref()); + await Promise.race([ + Promise.all([ + stopProfiling(), + Promise.resolve() + .then(() => running?.shutdown()) + .catch(() => undefined), + ]).then(() => undefined), + timeout, + ]); +} + +/** SDK warnings (a rejected export, a bad option) go through the redacting logger. When + * `OTEL_LOG_LEVEL` is set, NodeSDK installs its own console logger at that level for debugging, + * so this default stays out of its way. */ +function installDiagLogger(level: string | undefined): void { + if (level?.trim()) return; + diag.setLogger(diagLogger, { + logLevel: DiagLogLevel.WARN, + suppressOverrideMessage: true, + }); +} + +/** The endpoint's host only: a URL can carry credentials in its userinfo or query. */ +function hostOf(endpoint: string | undefined): string { + try { + return new URL(endpoint?.trim() ?? "").host; + } catch { + return "invalid"; + } +} diff --git a/src/shared-log.ts b/src/shared-log.ts new file mode 100644 index 0000000..025071a --- /dev/null +++ b/src/shared-log.ts @@ -0,0 +1,51 @@ +import type { Logger } from "pino"; +import { announceLogLevel, createLogger } from "./logger.js"; + +let configuredService: string | undefined; + +/** The configured service name, else `OTEL_SERVICE_NAME`, else "unknown". */ +export function currentService(): string { + return configuredService ?? (process.env.OTEL_SERVICE_NAME?.trim() || "unknown"); +} + +/** + * The one logger every process shares. Apps import it directly; modules and packages log through a + * child that binds `component` (`log.child({ component: "db" })`), so their lines carry the + * process's name in `app`. + */ +export const log: Logger = createLogger(currentService); + +/** + * Names the process on every log line (the `app` field), including lines from loggers created + * before this call. Each entry point calls it once. The first name wins: a later call with a + * different name is ignored and warns, so no imported module can rename the process. + */ +export function configureLogger({ service }: { service: string }): void { + if (configuredService === undefined) { + configuredService = service; + announceLogLevel(log); + return; + } + if (service !== configuredService) { + log.warn( + { event: "logger_reconfigure_ignored", requested: service }, + "logger already configured, keeping the first service name", + ); + } +} + +/** Resolves once buffered log lines are written, or after `timeoutMs`; never rejects. */ +export function flushLog(timeoutMs = 1_000): Promise { + return new Promise((resolve) => { + const timer = setTimeout(resolve, timeoutMs); + try { + log.flush(() => { + clearTimeout(timer); + resolve(); + }); + } catch { + clearTimeout(timer); + resolve(); + } + }); +} diff --git a/src/testing.ts b/src/testing.ts new file mode 100644 index 0000000..8c4f7dc --- /dev/null +++ b/src/testing.ts @@ -0,0 +1,14 @@ +import type { Logger } from "pino"; +import { createLogger } from "./logger.js"; +import { currentService } from "./shared-log.js"; + +/** A logger that captures parsed records in memory, for tests. */ +export function createTestLogger(): { logger: Logger; records: Record[] } { + const records: Record[] = []; + const logger = createLogger(currentService, { + write(line: string) { + records.push(JSON.parse(line) as Record); + }, + }); + return { logger, records }; +} diff --git a/src/trace-context.ts b/src/trace-context.ts new file mode 100644 index 0000000..e2bc8a5 --- /dev/null +++ b/src/trace-context.ts @@ -0,0 +1,87 @@ +import { + context, + defaultTextMapGetter, + defaultTextMapSetter, + isSpanContextValid, + ROOT_CONTEXT, + SpanStatusCode, + trace, + type Link, +} from "@opentelemetry/api"; +import { unsuppressTracing, W3CTraceContextPropagator } from "@opentelemetry/core"; + +// The W3C propagator is used directly, not the global one, so a stored value is always a +// `traceparent` header whatever OTEL_PROPAGATORS says. +const w3c = new W3CTraceContextPropagator(); +const TRACER_NAME = "@codelittinc/otel-node"; + +/** The W3C `traceparent` of the active span, or undefined when no span is active. */ +export function currentTraceparent(): string | undefined { + const carrier: Record = {}; + w3c.inject(context.active(), carrier, defaultTextMapSetter); + return carrier.traceparent; +} + +/** A span link to a stored `traceparent`. A missing or malformed value gives no link. */ +export function linkFromTraceparent(value: string | null | undefined): Link | undefined { + if (!value) return undefined; + try { + const extracted = w3c.extract(ROOT_CONTEXT, { traceparent: value }, defaultTextMapGetter); + const spanContext = trace.getSpanContext(extracted); + return spanContext && isSpanContextValid(spanContext) ? { context: spanContext } : undefined; + } catch { + return undefined; + } +} + +/** + * Run `fn` inside a new active span that links to the trace in `traceparent`. A link, not a + * parent: the work runs later and apart from the request that stored the value. + */ +export function withLinkedSpan( + name: string, + traceparent: string | null | undefined, + fn: () => Promise, +): Promise { + const link = linkFromTraceparent(traceparent); + return trace + .getTracer(TRACER_NAME) + .startActiveSpan(name, { links: link ? [link] : [] }, async (span) => { + try { + return await fn(); + } catch (err) { + span.recordException(err instanceof Error ? err : String(err)); + span.setStatus({ code: SpanStatusCode.ERROR }); + throw err; + } finally { + span.end(); + } + }); +} + +/** + * Run `fn` inside a new active root span, recorded even where the HTTP instrumentation suppressed + * tracing for an ignored path. The span ends with ERROR when `fn` throws or `failed` holds for its + * result, else OK. + */ +export function withRootSpan( + name: string, + fn: () => Promise, + failed: (result: T) => boolean = () => false, +): Promise { + return trace + .getTracer(TRACER_NAME) + .startActiveSpan(name, { root: true }, unsuppressTracing(context.active()), async (span) => { + try { + const result = await fn(); + span.setStatus({ code: failed(result) ? SpanStatusCode.ERROR : SpanStatusCode.OK }); + return result; + } catch (err) { + span.recordException(err instanceof Error ? err : String(err)); + span.setStatus({ code: SpanStatusCode.ERROR }); + throw err; + } finally { + span.end(); + } + }); +} diff --git a/src/url-credentials.ts b/src/url-credentials.ts new file mode 100644 index 0000000..80f28aa --- /dev/null +++ b/src/url-credentials.ts @@ -0,0 +1,163 @@ +// URL userinfo and query removal, shared by the message scrub and the URL value scrub. It imports +// nothing, so a browser bundle can load it from the `./url-credentials` entry. +// +// Every scan moves forward through the text once, so a scrub is linear in the input length. + +// Free text splits into URL tokens at a space or a line break only, so a tab or another space +// inside a password does not end its URL. +export const URL_TOKEN = /[^ \n\r]+/g; +// Where a URL's authority starts: after a scheme's `://` or JSON-escaped `:\/\/` (matched from +// the start of its run of scheme characters); after a scheme's `:/`, or a `//`, at the start of a +// token or after a delimiter. +export const URL_START = + /(?]/; +// A host and path also end at any whitespace. +export const URL_END = /[\s`<>]/; +// A quote ends a URL only at the token's end or before whitespace or a JSON or list delimiter. +export const QUOTE_END = /["'](?=$|[\s,;:}\])>"'&])/y; +// A `'` before `)` or `'`, which an encoded password keeps raw, does not end userinfo. +export const USERINFO_QUOTE = /'[)']/y; +// Text before an authority's first `/`, `?`, `#` or `\` that reads as a host and an optional port +// (a bracketed IPv6 host, or a non-empty host with no `:`, then `:` and digits or nothing). +export const HOST_PORT = /^(?:\[[^\]]*\]|[^:[\]]+)(?::\d*)?$/; +// The leading `scheme:` chain and one or two `/` (or `\/\/`), or a bare `//`, of a URL value. +export const URL_AUTHORITY = /^\s*(?:(?:[0-9+.-]*[a-z][a-z0-9+.-]*:)+(?:\/\/?|\\\/\\\/)|\/\/)/i; +export const QUERY_START = /[?#;]/; + +type Finder = (from: number) => number; + +/** The first index at or after `from` whose character passes `test`, or the text's length. */ +function finder(text: string, test: (char: string, index: number) => boolean): Finder { + let lastFrom = -1; + let found = -1; + return (from) => { + // Calls whose `from` never decreases reuse the last result, which keeps a scan linear. + if (from < lastFrom || found < from) { + found = from; + while (found < text.length && !test(text[found]!, found)) found++; + } + lastFrom = from; + return found; + }; +} + +const isDelimiter = (char: string) => char === "/" || char === "?" || char === "#" || char === "\\"; + +interface Scanner { + at: Finder; + delimiter: Finder; + slash: Finder; + queryValue: Finder; + delimiterAfterAt: Finder; +} + +function scanner(text: string): Scanner { + return { + at: finder(text, (char) => char === "@"), + delimiter: finder(text, isDelimiter), + slash: finder(text, (char) => char === "/"), + queryValue: finder(text, (char) => char === "=" || char === "/"), + delimiterAfterAt: finder(text, isDelimiter), + }; +} + +/** + * Where the authority at `from` continues past its userinfo, which runs to the last `@` before + * the first `/`, `?`, `#` or `\` after its first `@`, within `end`. Text before a delimiter that + * comes ahead of the first `@` is a host, not userinfo: + * - with a `:` or a leading `[`, when HOST_PORT matches and the head is bracketed, the delimiter + * is `/` or an escaped `\/`, or a `?` or `#` delimiter has a `=` or `/` after it before the `@`; + * - with neither, when a `/` comes before the `@`. + * A `//` or `://` inside userinfo starts no URL. Returns `from` when there is no userinfo. + */ +function afterUserinfo(text: string, from: number, end: number, scan: Scanner): number { + const at = scan.at(from); + if (at >= end) return from; + const delimiter = scan.delimiter(from); + if (delimiter < at) { + const head = text.slice(from, delimiter); + if (head.includes(":") || head.startsWith("[")) { + const sign = text[delimiter]; + const hostEvidence = + head.startsWith("[") || + sign === "/" || + (sign === "\\" ? text[delimiter + 1] === "/" : scan.queryValue(delimiter) < at); + if (hostEvidence && HOST_PORT.test(head)) return from; + } else if (scan.slash(delimiter) < at) { + return from; + } + } + const close = Math.min(scan.delimiterAfterAt(at + 1), end); + return text.lastIndexOf("@", close - 1) + 1; +} + +/** + * In a URL token, each URL loses its userinfo, and a URL with a `?`, `#` or `;` after its + * authority loses everything from there to the next whitespace. A host and path run to URL_END, + * QUOTE_END or the next URL's start, whichever comes first. + */ +export function scrubUrlToken(token: string): string { + URL_START.lastIndex = 0; + let start = URL_START.exec(token); + if (!start) return token; + const scan = scanner(token); + const quoteEnds = (char: string, index: number) => { + if (char !== '"' && char !== "'") return false; + QUOTE_END.lastIndex = index; + return QUOTE_END.test(token); + }; + const keptQuote = (index: number) => { + USERINFO_QUOTE.lastIndex = index; + return USERINFO_QUOTE.test(token); + }; + const userinfoEnd = finder( + token, + (char, i) => USERINFO_END.test(char) || (quoteEnds(char, i) && !keptQuote(i)), + ); + const urlEnd = finder(token, (char, i) => URL_END.test(char) || quoteEnds(char, i)); + const query = finder(token, (char) => QUERY_START.test(char)); + const space = finder(token, (char) => /\s/.test(char)); + let out = token.slice(0, start.index); + while (start) { + const authority = start.index + start[0].length; + const host = afterUserinfo(token, authority, userinfoEnd(authority), scan); + const end = urlEnd(host); + URL_START.lastIndex = host; + let next = URL_START.exec(token); + const cut = query(host); + out += token.slice(start.index, authority); + let resume = host; + if (cut < (next ? Math.min(next.index, end) : end)) { + out += token.slice(host, cut); + resume = space(cut); + if (next && next.index < resume) { + URL_START.lastIndex = resume; + next = URL_START.exec(token); + } + } + out += token.slice(resume, next ? next.index : token.length); + start = next; + } + return out; +} + +/** Every URL in free text, scrubbed as `scrubUrlToken` does. */ +export function scrubUrls(text: string): string { + return text.replace(URL_TOKEN, scrubUrlToken); +} + +/** + * A URL value without its userinfo. The authority starts after URL_AUTHORITY. With `schemeless`, + * a value without one that is not a path starts its authority at its first non-space character. + * The value is one URL, so its userinfo may run to the value's end. + */ +export function stripUserinfo(value: string, schemeless = false): string { + const lead = URL_AUTHORITY.exec(value); + const authority = lead ? lead[0].length : value.length - value.trimStart().length; + if (!lead && (!schemeless || value.startsWith("/", authority))) return value; + const host = afterUserinfo(value, authority, value.length, scanner(value)); + return value.slice(0, authority) + value.slice(host); +} diff --git a/src/url-path.ts b/src/url-path.ts new file mode 100644 index 0000000..1b8b3d5 --- /dev/null +++ b/src/url-path.ts @@ -0,0 +1,65 @@ +import { describePatterns } from "./scrub-descriptor.js"; + +/** + * Paths whose segments are bearer secrets, with the template each becomes. Anchored at the start + * of the path. An applicant intake token opens and submits an application for whoever holds it: + * the web page (`/apply/[token]`) and the api routes it calls. Any letter case and any run of `/` + * match, as a server routes them alike. + */ +export const TOKEN_PATH_TEMPLATES: readonly (readonly [RegExp, string])[] = [ + [/^\/+v1\/+applications\/+by-token\/+[^/?#]+/i, "/v1/applications/by-token/:token"], + [/^\/+apply\/+[^/?#]+/i, "/apply/[token]"], +]; + +// A `scheme:` chain and `//`, or a bare `//`, then the host. +const ORIGIN = /^((?:[a-z][a-z0-9+.-]*:)*\/\/[^/?#]*)(.*)$/is; + +// An `http.route` is a template only when it is a path. +const ROUTE_TEMPLATE_PREFIX = "/"; + +/** Pinned by scrub-contract.test.ts: the agent's layer-2 span scrub repeats these. */ +export const URL_PATH_PATTERNS = describePatterns({ ORIGIN }); +export const URL_PATH_LITERALS = Object.freeze({ ROUTE_TEMPLATE_PREFIX }); +export const TOKEN_PATH_CONTRACT = Object.freeze( + TOKEN_PATH_TEMPLATES.map(([pattern, replacement]) => + Object.freeze({ source: pattern.source, flags: pattern.flags, replacement }), + ), +); + +/** Splits `scheme://host/path` or `//host/path` into its origin and the rest; a path has none. */ +function splitOrigin(value: string): [string, string] { + const match = ORIGIN.exec(value); + return match ? [match[1]!, match[2]!] : ["", value]; +} + +function templatePath(path: string, tokenPaths: readonly (readonly [RegExp, string])[]): string { + return tokenPaths.reduce((out, [pattern, template]) => out.replace(pattern, template), path); +} + +/** + * The URL with its path replaced by the route template when there is one, else with any token + * segment templated. The origin is kept; the query and fragment are the caller's to strip. A + * leading `//` may open a path rather than a host, so it is never kept beside a route, and a + * token path right after it is templated as a path. Leading whitespace is kept as it is. + */ +export function templateUrlPath( + value: string, + route: string | undefined, + tokenPaths: readonly (readonly [RegExp, string])[] = TOKEN_PATH_TEMPLATES, +): string { + const url = value.trimStart(); + const lead = value.slice(0, value.length - url.length); + const [origin, rest] = splitOrigin(url); + if (origin.startsWith("/")) { + if (route) return `${lead}${route}`; + const asPath = templatePath(url, tokenPaths); + if (asPath !== url) return `${lead}${asPath}`; + } + if (route) return `${lead}${origin}${route}`; + return `${lead}${origin}${templatePath(rest, tokenPaths)}`; +} + +/** A usable route template: a path, and not the shared label for unmatched requests. */ +export function routeTemplateOf(route: unknown): string | undefined { + return typeof route === "string" && route.startsWith(ROUTE_TEMPLATE_PREFIX) ? route : undefined; +} diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..8a32c38 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "sourceMap": false, + "declarationMap": false + }, + "exclude": ["src/**/__tests__/**"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..b9c823a --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "types": ["node"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..2c3932e --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + include: ["src/**/__tests__/**/*.test.ts"], + }, +});