Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,10 +81,11 @@ Environment: `OTEL_EXPORTER_OTLP_ENDPOINT` (on/off), `OTEL_SDK_DISABLED=true` (k

| 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` | `startTelemetry`, `shutdownTelemetry`, `isTelemetryStarted`, `setHttpRoute`, `getMeter`, `log`, `configureLogger`, `flushLog`, `captureConsole`, `installFatalHandlers`, `boundedLabel`, `currentTraceparent`, `withLinkedSpan`, `withRootSpan`, `serializeError` (the scrubbed error shape `log` exports under `err`), `traceLogFields` (the active span's `trace_id` and `span_id`, or `{}`), 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/scrub` | `scrubMessage`, `scrubErrorText`, `stripQuery`, `templateUrlPath` and the read-only `TOKEN_PATH_TEMPLATES`: the server's own scrub functions, browser-safe, for client-side redaction. Imports nothing from Node or OpenTelemetry |
| `@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
Expand Down
2 changes: 2 additions & 0 deletions dist/index.d.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions dist/index.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion dist/log-fields.d.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion dist/log-fields.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion dist/log-serializer.d.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion dist/log-serializer.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions dist/redact.d.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 3 additions & 9 deletions dist/redact.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions dist/scrub-entry.d.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions dist/scrub-entry.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions dist/strip-query.d.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions dist/strip-query.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 4 additions & 4 deletions dist/url-path.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@codelittinc/otel-node",
"version": "0.1.0",
"version": "0.1.1",
"description": "In-process OpenTelemetry for Node services: SDK boot, layer-1 PII scrub, OTLP logs.",
"license": "UNLICENSED",
"repository": "github:codelittinc/otel-node",
Expand Down Expand Up @@ -29,6 +29,10 @@
"types": "./dist/register.d.ts",
"default": "./dist/register.js"
},
"./scrub": {
"types": "./dist/scrub-entry.d.ts",
"default": "./dist/scrub-entry.js"
},
"./url-credentials": {
"types": "./dist/url-credentials.d.ts",
"default": "./dist/url-credentials.js"
Expand Down
58 changes: 58 additions & 0 deletions src/__tests__/error-helpers-entry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { afterAll, describe, expect, it } from "vitest";
import { context, trace } from "@opentelemetry/api";
import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks";
import { BasicTracerProvider } from "@opentelemetry/sdk-trace-base";
import { serializeError, traceLogFields } from "../index.js";
import { createTestLogger } from "../testing.js";

const provider = new BasicTracerProvider();
context.setGlobalContextManager(new AsyncLocalStorageContextManager().enable());
trace.setGlobalTracerProvider(provider);
const tracer = provider.getTracer("error-helpers-test");

afterAll(async () => {
await provider.shutdown();
trace.disable();
context.disable();
});

describe("serializeError from the main entry", () => {
it("matches the err shape log exports, scrubbed and redacted", () => {
const error = Object.assign(new Error("bad ssn 123-45-6789"), {
password: "hunter2",
});
const { logger, records } = createTestLogger();
logger.error({ err: error }, "failed");

const shaped = serializeError(error);
expect(shaped).toEqual(records[0]!.err);
const text = JSON.stringify(shaped);
expect(text).not.toContain("123-45-6789");
expect(text).not.toContain("hunter2");
});
});

describe("traceLogFields from the main entry", () => {
it("returns the active span ids", () => {
tracer.startActiveSpan("work", (span) => {
const { traceId, spanId } = span.spanContext();
expect(traceLogFields()).toEqual({ trace_id: traceId, span_id: spanId });
span.end();
});
});

it("returns {} when the active span has an invalid context", () => {
const span = trace.wrapSpanContext({
traceId: "0".repeat(32),
spanId: "0".repeat(16),
traceFlags: 0,
});
context.with(trace.setSpan(context.active(), span), () => {
expect(traceLogFields()).toEqual({});
});
});

it("returns {} with no active span", () => {
expect(traceLogFields()).toEqual({});
});
});
7 changes: 5 additions & 2 deletions src/__tests__/types-without-bullmq.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,16 +33,19 @@ function typeErrors(source: string): string[] {

// 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.
// Each case builds a full compiler program, which a cold CI runner takes well over 5 s to do.
const CI_TIMEOUT = 60_000;

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([]);
});
}, CI_TIMEOUT);

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/);
});
}, CI_TIMEOUT);
});
75 changes: 69 additions & 6 deletions src/__tests__/url-credentials-entry.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import * as scrubEntry from "../scrub-entry.js";
import { scrubErrorText, scrubMessage } from "../log-scrub.js";
import { stripQuery } from "../strip-query.js";
import { TOKEN_PATH_TEMPLATES, templateUrlPath } from "../url-path.js";
import { stripQuery as redactStripQuery } from "../redact.js";

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/;
const IMPORT =
/\b(?:import|export)\s[^'"]*?from\s*["']([^"']+)["']|\bimport\s*\(\s*["']([^"']+)["']|\bimport\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<string, string> {
Expand All @@ -15,7 +22,7 @@ function importGraph(entry: string): Map<string, string> {
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])!;
const specifier = (match[1] ?? match[2] ?? match[3])!;
if (!specifier.startsWith("./")) {
seen.set(`external:${specifier}`, "");
continue;
Expand All @@ -32,12 +39,16 @@ 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([]);
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, "");
const code = source
.replace(/\/\/.*$/gm, "")
.replace(/\/\*[\s\S]*?\*\//g, "");
expect(NODE_GLOBALS.test(code), file).toBe(false);
}
});
Expand All @@ -46,6 +57,58 @@ describe("the url-credentials entry", () => {
const manifest = JSON.parse(
readFileSync(new URL("../../package.json", import.meta.url), "utf8"),
) as { exports: Record<string, { default: string }> };
expect(manifest.exports["./url-credentials"]?.default).toBe("./dist/url-credentials.js");
expect(manifest.exports["./url-credentials"]?.default).toBe(
"./dist/url-credentials.js",
);
});
});

// WHY: the browser bundles the `./scrub` entry for client-side redaction, so it carries the same
// import limits as `./url-credentials`.
describe("the scrub entry", () => {
const graph = importGraph("scrub-entry.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("exports the same function objects the server scrub uses", () => {
expect(scrubEntry.scrubMessage).toBe(scrubMessage);
expect(scrubEntry.scrubErrorText).toBe(scrubErrorText);
expect(scrubEntry.stripQuery).toBe(stripQuery);
expect(scrubEntry.TOKEN_PATH_TEMPLATES).toBe(TOKEN_PATH_TEMPLATES);
expect(scrubEntry.templateUrlPath).toBe(templateUrlPath);
expect(redactStripQuery).toBe(stripQuery);
});

it("exports a token path list no consumer can change", () => {
const list = scrubEntry.TOKEN_PATH_TEMPLATES as unknown as unknown[][];
const before = JSON.stringify(list.map(([re, to]) => [String(re), to]));
expect(() => list.push([/x/, "y"])).toThrow(TypeError);
expect(() => list.splice(0, 1)).toThrow(TypeError);
expect(() => {
list[0]![1] = "weakened";
}).toThrow(TypeError);
expect(JSON.stringify(list.map(([re, to]) => [String(re), to]))).toBe(
before,
);
});

it("is exported as its own subpath", () => {
const manifest = JSON.parse(
readFileSync(new URL("../../package.json", import.meta.url), "utf8"),
) as { exports: Record<string, { default: string }> };
expect(manifest.exports["./scrub"]?.default).toBe("./dist/scrub-entry.js");
});
});
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,5 @@ export { captureConsole } from "./console-capture.js";
export { installFatalHandlers } from "./fatal.js";
export { boundedLabel } from "./bounded.js";
export { currentTraceparent, withLinkedSpan, withRootSpan } from "./trace-context.js";
export { serializeError } from "./log-serializer.js";
export { traceLogFields } from "./log-fields.js";
2 changes: 1 addition & 1 deletion src/log-fields.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { isSpanContextValid, trace } from "@opentelemetry/api";

/** Trace correlation fields for the active span; empty outside a valid span. */
/** Returns the active span's `trace_id` and `span_id`, or `{}` when there is no active span. */
export function traceLogFields(): { trace_id?: string; span_id?: string } {
const spanContext = trace.getActiveSpan()?.spanContext();
if (!spanContext || !isSpanContextValid(spanContext)) return {};
Expand Down
2 changes: 1 addition & 1 deletion src/log-serializer.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
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. */
/** Serializes one error value to the scrubbed shape `log` exports under `err`: message and stack scrubbed, only allowlisted fields kept, never throws. */
export function serializeError(value: unknown): unknown {
try {
return serializeErrorBase(value);
Expand Down
Loading
Loading