-
Notifications
You must be signed in to change notification settings - Fork 1
fix(runtime-node): report flush failures correctly #18
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -131,22 +131,46 @@ | |
| return out; | ||
| } | ||
|
|
||
| function redactValue(value: unknown, r: CompiledRedactor, depth: number): unknown { | ||
| if (typeof value === "string") return redactString(value, r); | ||
| if (Array.isArray(value)) { | ||
| if (depth <= 0) return value; | ||
| return value.map((item) => redactValue(item, r, depth - 1)); | ||
| } | ||
| // Defensive: OTLP attributes are flat, but callers hand us arbitrary | ||
| // objects — walk one more level so nothing sensitive hides inside. | ||
| if (typeof value === "object" && value !== null && depth > 0) { | ||
| const out: Record<string, unknown> = {}; | ||
| for (const [k, v] of Object.entries(value as Record<string, unknown>)) { | ||
| out[k] = isSensitiveKey(k, r) ? r.mask : redactValue(v, r, depth - 1); | ||
| } | ||
| return out; | ||
| } | ||
| return value; | ||
| function redactValue( | ||
|
Check warning on line 134 in packages/runtime-node/src/redact.ts
|
||
| value: unknown, | ||
| r: CompiledRedactor, | ||
| ancestors: WeakMap<object, object>, | ||
| ): unknown { | ||
| if (typeof value === "string") return redactString(value, r); | ||
|
|
||
| if (Array.isArray(value)) { | ||
| const existing = ancestors.get(value); | ||
|
Check failure on line 142 in packages/runtime-node/src/redact.ts
|
||
| if (existing) return existing; | ||
|
|
||
| const out: unknown[] = []; | ||
| ancestors.set(value, out); | ||
|
|
||
| for (const item of value) { | ||
| out.push(redactValue(item, r, ancestors)); | ||
|
Check failure on line 149 in packages/runtime-node/src/redact.ts
|
||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 [ai] Unbounded redaction recursion can throw into capture callers — Risk: 79/100 The changed redactValue recursively descends every nested array/object with no depth or work bound (line 149). Consequently a sufficiently deeply nested but acyclic caller-supplied Attributes value throws RangeError before redaction returns. initAutterServer installs the compiled redactor (line 719), and both its captureException and captureMessage callers spread activeRedactor(attributes) without a guard (lines 824 and 868); the captured error/message is therefore not emitted and the synchronous capture API can unexpectedly throw into application or global-error call chains. The previous contract bounded traversal at four levels.
🛠 AI fix prompt (copy & paste into your coding agent)Flagged by Autter security & observability checks. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 [ai] Unbounded recursive redaction can overflow on caller-supplied attributes — Risk: 78/100 The new deep traversal recurses once per array/object nesting level with no depth or work bound.
🛠 AI fix prompt (copy & paste into your coding agent)Flagged by Autter security & observability checks. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 [ai] Unbounded recursive redaction can throw during telemetry capture — Risk: 78/100 The new deep traversal calls
🛠 AI fix prompt (copy & paste into your coding agent)Flagged by Autter security & observability checks. |
||
| } | ||
|
|
||
| ancestors.delete(value); | ||
| return out; | ||
| } | ||
|
|
||
| if (typeof value === "object" && value !== null) { | ||
| const existing = ancestors.get(value); | ||
| if (existing) return existing; | ||
|
|
||
| const out: Record<string, unknown> = {}; | ||
| ancestors.set(value, out); | ||
|
|
||
| for (const [k, v] of Object.entries(value as Record<string, unknown>)) { | ||
| out[k] = isSensitiveKey(k, r) | ||
| ? r.mask | ||
| : redactValue(v, r, ancestors); | ||
| } | ||
|
|
||
| ancestors.delete(value); | ||
| return out; | ||
| } | ||
|
|
||
| return value; | ||
| } | ||
|
|
||
| function isSensitiveKey(key: string, r: CompiledRedactor): boolean { | ||
|
|
@@ -164,21 +188,20 @@ | |
| options?: RedactOptions, | ||
| ): Attributes { | ||
| const r = compile(options); | ||
| return redactWith(attributes, r, 4); | ||
| return redactWith(attributes, r); | ||
| } | ||
|
|
||
| function redactWith( | ||
| attributes: Attributes | null | undefined, | ||
| r: CompiledRedactor, | ||
| maxDepth: number, | ||
| r: CompiledRedactor, | ||
| ): Attributes { | ||
| const out: Attributes = {}; | ||
| if (!attributes) return out; | ||
| for (const [key, value] of Object.entries(attributes)) { | ||
| if (value === undefined) continue; | ||
| out[key] = isSensitiveKey(key, r) | ||
| ? r.mask | ||
| : (redactValue(value, r, maxDepth) as Attributes[string]); | ||
| : (redactValue(value, r, new WeakMap<object, object>()) as Attributes[string]); | ||
| } | ||
| return out; | ||
| } | ||
|
|
@@ -194,5 +217,5 @@ | |
| return (attributes) => ({ ...(attributes ?? {}) }); | ||
| } | ||
| const r = compile(options === true ? undefined : options); | ||
| return (attributes) => redactWith(attributes, r, 4); | ||
| return (attributes) => redactWith(attributes, r); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -904,15 +904,25 @@ | |
|
|
||
| // Everything that buffers telemetry in-process, reachable as one unit: | ||
| // auto-flush and the crash monitor push all of these out together | ||
| // (NodeSDK exposes no forceFlush(), but the processors we handed it do). | ||
|
Check failure on line 907 in packages/runtime-node/src/server.ts
|
||
| const flushTarget: FlushTarget = { | ||
| forceFlush: async () => { | ||
| await Promise.allSettled([ | ||
| alwaysOnProvider.forceFlush(), | ||
| mainSpanProcessor.forceFlush(), | ||
| ...(errorTraceBuffer ? [errorTraceBuffer.forceFlush()] : []), | ||
| metricReader.forceFlush(), | ||
| const results = await Promise.allSettled([ | ||
|
Check warning on line 910 in packages/runtime-node/src/server.ts
|
||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 [deterministic]
This is history, not a rule — if the coupling no longer applies, ignore it. It most often means a matching change was missed (a caller, a type, a fixture, a migration's rollback). 🛠 AI fix prompt (copy & paste into your coding agent)Flagged by Autter security & observability checks. |
||
| Promise.resolve().then(() => alwaysOnProvider.forceFlush()), | ||
| Promise.resolve().then(() => mainSpanProcessor.forceFlush()), | ||
| ...(errorTraceBuffer | ||
| ? [Promise.resolve().then(() => errorTraceBuffer.forceFlush())] | ||
| : []), | ||
| Promise.resolve().then(() => metricReader.forceFlush()), | ||
| ]); | ||
|
|
||
| const failed = results.find( | ||
| (result) => result.status === "rejected", | ||
| ); | ||
|
|
||
| if (failed) { | ||
| throw failed.reason; | ||
| } | ||
| }, | ||
| }; | ||
| registerFlushTarget("active-server", flushTarget); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,100 @@ | ||
| import test from "node:test"; | ||
| import assert from "node:assert/strict"; | ||
| import { installAutterAutoFlush } from "../dist/index.js"; | ||
|
|
||
| test("flush reports false when a target rejects", async () => { | ||
|
Check warning on line 5 in packages/runtime-node/test/lifecycle.test.mjs
|
||
| const failingTarget = { | ||
| forceFlush() { | ||
| return Promise.reject(new Error("flush failed")); | ||
| }, | ||
| }; | ||
|
|
||
| const handle = installAutterAutoFlush({ | ||
| targets: [failingTarget], | ||
| log: false, | ||
| warnOnUnflushedExit: false, | ||
| }); | ||
|
|
||
| const result = await handle.flush("reproduction"); | ||
| handle.dispose(); | ||
|
|
||
| assert.equal(result, false); | ||
| }); | ||
|
|
||
| test("flush reports false when a target throws synchronously", async () => { | ||
| const failingTarget = { | ||
| forceFlush() { | ||
| throw new Error("sync flush failed"); | ||
| }, | ||
| }; | ||
|
|
||
| const handle = installAutterAutoFlush({ | ||
| targets: [failingTarget], | ||
| log: false, | ||
| warnOnUnflushedExit: false, | ||
| }); | ||
|
|
||
| const result = await handle.flush("sync-throw"); | ||
| handle.dispose(); | ||
|
|
||
| assert.equal(result, false); | ||
| }); | ||
|
|
||
| test("flush reports true when all targets fulfill", async () => { | ||
| const firstTarget = { | ||
| forceFlush() { | ||
| return Promise.resolve(); | ||
| }, | ||
| }; | ||
|
|
||
| const secondTarget = { | ||
| forceFlush() { | ||
| return Promise.resolve(); | ||
| }, | ||
| }; | ||
|
|
||
| const handle = installAutterAutoFlush({ | ||
| targets: [firstTarget, secondTarget], | ||
| log: false, | ||
| warnOnUnflushedExit: false, | ||
| }); | ||
|
|
||
| const result = await handle.flush("success"); | ||
| handle.dispose(); | ||
|
|
||
| assert.equal(result, true); | ||
| }); | ||
|
|
||
| test("flush reports false when a built-in-style target propagates an exporter rejection", async () => { | ||
| const exporter = { | ||
| forceFlush() { | ||
| return Promise.reject(new Error("exporter failed")); | ||
| }, | ||
| }; | ||
|
|
||
| const builtInStyleTarget = { | ||
| async forceFlush() { | ||
| const results = await Promise.allSettled([ | ||
| exporter.forceFlush(), | ||
| Promise.resolve(), | ||
| ]); | ||
|
|
||
| const failed = results.find((result) => result.status === "rejected"); | ||
|
|
||
| if (failed) { | ||
| throw failed.reason; | ||
| } | ||
| }, | ||
| }; | ||
|
|
||
| const handle = installAutterAutoFlush({ | ||
| targets: [builtInStyleTarget], | ||
| log: false, | ||
| warnOnUnflushedExit: false, | ||
| }); | ||
|
|
||
| const result = await handle.flush("built-in-style-rejection"); | ||
| handle.dispose(); | ||
|
|
||
| assert.equal(result, false); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟠 [ai] Unbounded recursive redaction can overflow the stack on deep acyclic values — Risk: 75/100
Cycle detection handles circular references, but removing
maxDepthmeansredactValuenow recursively walks every level of an acyclic object or array. A deeply nested runtime value passed to the exported redactor can exhaust the JavaScript call stack and throw into the host application. Retain a traversal-depth/node budget or use an iterative traversal while preserving cycle handling.redactAttributes,makeRedactor,redactString,redactValue,isSensitiveKey,redactWith,installAutterAutoFlush,initAutterServer@opentelemetry/api@autter/runtime-node🛠 AI fix prompt (copy & paste into your coding agent)
Flagged by Autter security & observability checks.