Repository navigation
feat: extract trace context from MSK events #830
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 |
|---|---|---|
| @@ -0,0 +1,122 @@ | ||
| import { MSKEvent } from "aws-lambda"; | ||
| import { MSKEventTraceExtractor } from "./msk"; | ||
| import { TracerWrapper } from "../../tracer-wrapper"; | ||
| import { SpanContextWrapper } from "../../span-context-wrapper"; | ||
| import { TraceSource } from "../../trace-context-service"; | ||
|
|
||
| const datadogHeaders = { | ||
| "x-datadog-trace-id": "667309514221035538", | ||
| "x-datadog-parent-id": "1350735035497811828", | ||
| "x-datadog-sampling-priority": "1", | ||
| "x-datadog-tags": "_dd.p.tid=0123456789abcdef", | ||
| }; | ||
| const w3cHeaders = { | ||
| traceparent: "00-0123456789abcdef0942c4f3b84c8812-12becbb3b2d5bd74-01", | ||
| tracestate: "dd=s:1;t.dm:-0", | ||
| }; | ||
| const record = (headers: Record<string, string>) => ({ | ||
| headers: Object.entries(headers).map(([name, value]) => ({ [name]: Array.from(Buffer.from(value)) })), | ||
| }); | ||
| const event = (records: any): MSKEvent => ({ eventSource: "aws:kafka", records } as MSKEvent); | ||
|
|
||
| // Mock the tracer boundary so these tests exercise MSK decoding and record selection. | ||
| describe("MSKEventTraceExtractor", () => { | ||
| const spanContext = new SpanContextWrapper( | ||
| { toTraceId: () => datadogHeaders["x-datadog-trace-id"], toSpanId: () => datadogHeaders["x-datadog-parent-id"] }, | ||
| TraceSource.Event, | ||
| ); | ||
| let extract: jest.Mock; | ||
| let extractor: MSKEventTraceExtractor; | ||
|
|
||
| beforeEach(() => { | ||
| extract = jest.fn().mockReturnValue(null); | ||
| extractor = new MSKEventTraceExtractor({ extract } as unknown as TracerWrapper); | ||
| }); | ||
|
|
||
| it.each([ | ||
| ["Datadog", datadogHeaders], | ||
| ["W3C", w3cHeaders], | ||
| ["combined", { ...datadogHeaders, ...w3cHeaders }], | ||
| ])("decodes and forwards all %s propagation headers", (_, headers) => { | ||
| extract.mockReturnValue(spanContext); | ||
| expect(extractor.extract(event({ "topic-0": [record(headers)] }))).toBe(spanContext); | ||
| expect(extract).toHaveBeenCalledWith(headers); | ||
| expect(extract).toHaveBeenCalledTimes(1); | ||
| }); | ||
|
|
||
| it("normalizes header names and decodes UTF-8", () => { | ||
| extractor.extract( | ||
| event({ "topic-0": [record({ TraceParent: w3cHeaders.traceparent, baggage: "city=São Paulo" })] }), | ||
| ); | ||
| expect(extract).toHaveBeenCalledWith({ traceparent: w3cHeaders.traceparent, baggage: "city=São Paulo" }); | ||
| }); | ||
|
|
||
| it("uses the last value for duplicate headers", () => { | ||
| const message = { headers: [...record({ traceparent: "old" }).headers, ...record(w3cHeaders).headers] }; | ||
| extractor.extract(event({ "topic-0": [message] })); | ||
| expect(extract).toHaveBeenCalledWith(w3cHeaders); | ||
| }); | ||
|
|
||
| it("skips untraced records across partitions and stops at the first valid context", () => { | ||
| extract.mockReturnValueOnce(null).mockReturnValueOnce(spanContext); | ||
| expect( | ||
| extractor.extract( | ||
| event({ | ||
| "topic-0": [{ headers: [] }, record({ traceparent: "invalid" })], | ||
| "topic-1": [record(datadogHeaders), record(w3cHeaders)], | ||
| }), | ||
| ), | ||
| ).toBe(spanContext); | ||
| expect(extract).toHaveBeenCalledTimes(2); | ||
| expect(extract).toHaveBeenNthCalledWith(2, datadogHeaders); | ||
| }); | ||
|
|
||
| it("never combines partial trace headers from different records", () => { | ||
| extractor.extract( | ||
| event({ "topic-0": [record({ "x-datadog-trace-id": "123" }), record({ "x-datadog-parent-id": "456" })] }), | ||
| ); | ||
| expect(extract).toHaveBeenNthCalledWith(1, { "x-datadog-trace-id": "123" }); | ||
| expect(extract).toHaveBeenNthCalledWith(2, { "x-datadog-parent-id": "456" }); | ||
| }); | ||
|
|
||
| it.each([null, "123", { "0": 49 }].map((value) => [value]))("skips non-array header values: %j", (value) => { | ||
| extract.mockReturnValue(spanContext); | ||
| expect( | ||
| extractor.extract(event({ "topic-0": [{ headers: [{ invalid: value }, ...record(datadogHeaders).headers] }] })), | ||
| ).toBe(spanContext); | ||
| expect(extract).toHaveBeenCalledWith(datadogHeaders); | ||
| }); | ||
|
|
||
| it.each([ | ||
| undefined, | ||
| null, | ||
| "invalid", | ||
| {}, | ||
| { "topic-0": [] }, | ||
| { "topic-0": null }, | ||
| { "topic-0": "invalid" }, | ||
| { "topic-0": [null, {}, { headers: null }, { headers: "invalid" }, { headers: [null, 42, []] }] }, | ||
| ])("returns null for empty or malformed records: %j", (records) => { | ||
| expect(extractor.extract(event(records))).toBeNull(); | ||
| expect(extract).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it("returns null after an extraction error", () => { | ||
| extract.mockImplementationOnce(() => { | ||
| throw new Error("invalid carrier"); | ||
| }); | ||
| expect(extractor.extract(event({ "topic-0": [record(w3cHeaders), record(datadogHeaders)] }))).toBeNull(); | ||
| expect(extract).toHaveBeenCalledTimes(1); | ||
| }); | ||
|
|
||
| it("returns null when the tracer cannot extract context", () => { | ||
| expect(extractor.extract(event({ "topic-0": [record(datadogHeaders)] }))).toBeNull(); | ||
| }); | ||
|
|
||
| it("does not mutate the event", () => { | ||
| const payload = event({ "topic-0": [record(datadogHeaders)] }); | ||
| const original = JSON.stringify(payload); | ||
| extractor.extract(payload); | ||
| expect(JSON.stringify(payload)).toBe(original); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,56 @@ | ||
| import { MSKEvent, MSKRecord } from "aws-lambda"; | ||
| import { logDebug } from "../../../utils"; | ||
| import { EventTraceExtractor } from "../extractor"; | ||
| import { handleExtractionError } from "../extractor-utils"; | ||
| import { TracerWrapper } from "../../tracer-wrapper"; | ||
| import { SpanContextWrapper } from "../../span-context-wrapper"; | ||
|
|
||
| export class MSKEventTraceExtractor implements EventTraceExtractor { | ||
| constructor(private tracerWrapper: TracerWrapper) {} | ||
|
|
||
| extract(event: MSKEvent): SpanContextWrapper | null { | ||
| if (!event?.records) { | ||
| logDebug("Failed to extract trace context from MSK event"); | ||
| return null; | ||
| } | ||
|
|
||
| try { | ||
| // A Lambda span can have only one parent. Use the first record with valid | ||
| // trace context, without combining headers from different records. | ||
| for (const records of Object.values(event.records)) { | ||
| if (!Array.isArray(records)) continue; | ||
| for (const record of records) { | ||
| const headers = this.getParsedRecordHeaders(record); | ||
| if (!headers) continue; | ||
| const traceContext = this.tracerWrapper.extract(headers); | ||
| if (traceContext) { | ||
| logDebug("Extracted trace context from MSK event"); | ||
| return traceContext; | ||
| } | ||
| } | ||
| } | ||
| } catch (error) { | ||
| handleExtractionError(error, "MSK"); | ||
| } | ||
|
|
||
| logDebug("Failed to extract trace context from MSK event"); | ||
| return null; | ||
|
lucassarcanjo marked this conversation as resolved.
|
||
| } | ||
|
|
||
| private getParsedRecordHeaders(record: MSKRecord): Record<string, string> | null { | ||
| if (!Array.isArray(record?.headers)) return null; | ||
|
|
||
| let headers: Record<string, string> | null = null; | ||
| for (const entry of record.headers) { | ||
| if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue; | ||
| for (const [name, value] of Object.entries(entry)) { | ||
| // MSK serializes Kafka header bytes as integer arrays, not base64. | ||
| if (Array.isArray(value)) { | ||
| headers ??= {}; | ||
| headers[name.toLowerCase()] = Buffer.from(value).toString("utf8"); | ||
| } | ||
| } | ||
| } | ||
| return headers; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,6 +4,7 @@ import { | |
| AppSyncResolverEvent, | ||
| EventBridgeEvent, | ||
| KinesisStreamEvent, | ||
| MSKEvent, | ||
| SNSEvent, | ||
| SNSMessage, | ||
| SQSEvent, | ||
|
|
@@ -75,6 +76,15 @@ export class EventValidator { | |
| return Array.isArray(event.Records) && event.Records.length > 0 && event.Records[0].kinesis !== undefined; | ||
| } | ||
|
|
||
| static isMSKEvent(event: any): event is MSKEvent { | ||
| return ( | ||
| event?.eventSource === "aws:kafka" && | ||
| event.records !== null && | ||
| typeof event.records === "object" && | ||
| !Array.isArray(event.records) | ||
| ); | ||
|
Contributor
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. Lambda's self-managed Kafka trigger (any non-MSK cluster, e.g. on EC2 or Confluent Cloud) sends the same records and headers shape as MSK, but with Could we accept both values? static isKafkaEvent(event: any): event is MSKEvent | SelfManagedKafkaEvent {
return (
(event?.eventSource === "aws:kafka" || event?.eventSource === "SelfManagedKafka") &&
event.records !== null &&
typeof event.records === "object" &&
!Array.isArray(event.records)
);
}The header-decoding logic should work for both as-is. A test fixture with |
||
| } | ||
|
|
||
| static isDurableExecutionEvent(event: any): boolean { | ||
| return typeof event.DurableExecutionArn === "string"; | ||
| } | ||
|
|
||
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.
Every other batch extractor takes trace context from the first record only:
sqs.ts,sns.ts,sns-sqs.ts,event-bridge-sqs.ts, andkinesis.tsall useRecords[0]. Their loops over every record are only for DSM checkpoints. Can we do the same here? Read the first record of the first topic-partition, try to extract from it, and return.This keeps MSK consistent with the other event sources, so the Lambda span's parent is always the first record. It also avoids decoding headers and calling
tracer.extracton up to 10k records in every untraced batch.The test cases that expect a later record's context to win would need to change to expect
null.