diff --git a/event_samples/lambda-function-urls.json b/event_samples/lambda-function-urls.json new file mode 100644 index 00000000..324dae52 --- /dev/null +++ b/event_samples/lambda-function-urls.json @@ -0,0 +1,46 @@ +{ + "version": "2.0", + "routeKey": "$default", + "rawPath": "/", + "rawQueryString": "", + "headers": { + "sec-fetch-mode": "navigate", + "sec-fetch-site": "none", + "accept-language": "en-US,en;q=0.9", + "x-forwarded-proto": "https", + "x-forwarded-port": "443", + "x-forwarded-for": "71.195.30.42", + "sec-fetch-user": "?1", + "pragma": "no-cache", + "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9", + "sec-ch-ua": "\"Google Chrome\";v=\"95\", \"Chromium\";v=\"95\", \";Not A Brand\";v=\"99\"", + "sec-ch-ua-mobile": "?0", + "x-amzn-trace-id": "Root=1-61953929-1ec00c3011062a48477b169e", + "sec-ch-ua-platform": "\"macOS\"", + "host": "a8hyhsshac.lambda-url.eu-south-1.amazonaws.com", + "upgrade-insecure-requests": "1", + "cache-control": "no-cache", + "accept-encoding": "gzip, deflate, br", + "sec-fetch-dest": "document", + "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/95.0.4638.69 Safari/537.36" + }, + "requestContext": { + "accountId": "601427279990", + "apiId": "a8hyhsshac", + "domainName": "a8hyhsshac.lambda-url.eu-south-1.amazonaws.com", + "domainPrefix": "a8hyhsshac", + "http": { + "method": "GET", + "path": "/", + "protocol": "HTTP/1.1", + "sourceIp": "71.195.30.42", + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/95.0.4638.69 Safari/537.36" + }, + "requestId": "ec4d58f8-2b8b-4ceb-a1d5-2be7bff58505", + "routeKey": "$default", + "stage": "$default", + "time": "17/Nov/2021:17:17:29 +0000", + "timeEpoch": 1637169449721 + }, + "isBase64Encoded": false +} diff --git a/src/index.spec.ts b/src/index.spec.ts index 4caef746..c4414110 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -331,7 +331,7 @@ describe("datadog", () => { expect(mockedIncrementInvocations).toBeCalledTimes(1); expect(mockedIncrementInvocations).toBeCalledWith(expect.anything(), mockContext); - expect(logger.debug).toHaveBeenCalledTimes(9); + expect(logger.debug).toHaveBeenCalledTimes(10); expect(logger.debug).toHaveBeenLastCalledWith('{"status":"debug","message":"datadog:Unpatching HTTP libraries"}'); }); }); diff --git a/src/index.ts b/src/index.ts index 0f9195f8..c370ffa9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -150,6 +150,9 @@ export function datadog( if (traceListener.currentSpan) { traceListener.currentSpan.setTag("http.status_code", statusCode); } + if (traceListener.currentInferredSpan) { + traceListener.currentInferredSpan.setTag("http.status_code", statusCode); + } } } } diff --git a/src/trace/listener.ts b/src/trace/listener.ts index 5f1fcf22..78e413de 100644 --- a/src/trace/listener.ts +++ b/src/trace/listener.ts @@ -17,6 +17,7 @@ import { datadogLambdaVersion } from "../constants"; import { Source, ddtraceVersion } from "./constants"; import { patchConsole } from "./patch-console"; import { SpanContext, TraceOptions, TracerWrapper } from "./tracer-wrapper"; +import { SpanInferrer } from "./span-inferrer"; export type TraceExtractor = (event: any, context: Context) => TraceContext; @@ -50,6 +51,8 @@ export class TraceListener { private context?: Context; private stepFunctionContext?: StepFunctionContext; private tracerWrapper: TracerWrapper; + private inferrer: SpanInferrer; + private inferredSpan: any; public triggerTags?: { [key: string]: string }; public get currentTraceHeaders() { @@ -58,9 +61,13 @@ export class TraceListener { public get currentSpan() { return this.tracerWrapper.currentSpan; } + public get currentInferredSpan() { + return this.inferredSpan; + } constructor(private config: TraceConfig, private handlerName: string) { this.tracerWrapper = new TracerWrapper(); this.contextService = new TraceContextService(this.tracerWrapper); + this.inferrer = new SpanInferrer(this.tracerWrapper); } public onStartInvocation(event: any, context: Context) { @@ -79,7 +86,8 @@ export class TraceListener { } else { logDebug("Not patching HTTP libraries", { autoPatchHTTP: this.config.autoPatchHTTP, tracerInitialized }); } - + logDebug("Creating inferred span"); + this.inferredSpan = this.inferrer.createInferredSpan(event, context); this.context = context; this.triggerTags = extractTriggerTags(event, context); this.contextService.rootTraceContext = extractTraceContext(event, context, this.config.traceExtractor); @@ -98,6 +106,10 @@ export class TraceListener { logDebug("Unpatching HTTP libraries"); unpatchHttp(); } + if (this.inferredSpan) { + logDebug("Finishing inferred span"); + this.inferredSpan.finish(Date.now()); + } } public onWrap any>(func: T): T { @@ -147,8 +159,12 @@ export class TraceListener { ...this.stepFunctionContext, }; } - - if (parentSpanContext !== null) { + if (this.inferredSpan) { + options.childOf = this.inferredSpan; + if (parentSpanContext !== null) { + this.inferredSpan.childOf = parentSpanContext; + } + } else if (parentSpanContext !== null) { options.childOf = parentSpanContext; } options.type = "serverless"; diff --git a/src/trace/span-inferrer.spec.ts b/src/trace/span-inferrer.spec.ts new file mode 100644 index 00000000..ef70696a --- /dev/null +++ b/src/trace/span-inferrer.spec.ts @@ -0,0 +1,31 @@ +import { SpanInferrer } from "./span-inferrer"; +import { TracerWrapper } from "./tracer-wrapper"; +const lambdaURLEvent = require("../../event_samples/lambda-function-urls.json"); + +const mockWrapper = { + startSpan: jest.fn(), +}; + +describe("SpanInferrer", () => { + it("creates an inferred span for lambda function URLs", () => { + const inferrer = new SpanInferrer(mockWrapper as unknown as TracerWrapper); + inferrer.createInferredSpan(lambdaURLEvent, { + awsRequestId: "abcd-1234", + } as any); + + expect(mockWrapper.startSpan).toBeCalledWith("aws.lambda.url", { + service: "aws.lambda", + startTime: 1637169449721, + tags: { + endpoint: "/", + "http.method": "GET", + "http.url": "a8hyhsshac.lambda-url.eu-south-1.amazonaws.com/", + operation_name: "aws.lambda.url", + request_id: "abcd-1234", + "resource.name": "a8hyhsshac.lambda-url.eu-south-1.amazonaws.com/", + resource_names: "a8hyhsshac.lambda-url.eu-south-1.amazonaws.com/", + "span.type": "http", + }, + }); + }); +}); diff --git a/src/trace/span-inferrer.ts b/src/trace/span-inferrer.ts new file mode 100644 index 00000000..fb596947 --- /dev/null +++ b/src/trace/span-inferrer.ts @@ -0,0 +1,37 @@ +import { Context } from "aws-lambda"; +import { SpanOptions, TracerWrapper } from "./tracer-wrapper"; +import { eventSources, parseEventSource } from "./trigger"; + +export class SpanInferrer { + traceWrapper: TracerWrapper; + constructor(traceWrapper: TracerWrapper) { + this.traceWrapper = traceWrapper; + } + + public createInferredSpan(event: any, context: Context | undefined): any { + const eventSource = parseEventSource(event); + if (eventSource === eventSources.lambdaUrl) { + return this.createInferredSpanForLambdaUrl(event, context); + } + } + + createInferredSpanForLambdaUrl(event: any, context: Context | undefined): any { + const options: SpanOptions = {}; + const domain = event.requestContext.domainName; + const path = event.rawPath; + options.tags = { + operation_name: "aws.lambda.url", + "http.url": domain + path, + endpoint: path, + "http.method": event.requestContext.http.method, + resource_names: domain + path, + request_id: context?.awsRequestId, + "span.type": "http", + "resource.name": domain + path, + }; + options.service = "aws.lambda"; + options.startTime = event.requestContext.timeEpoch; + + return this.traceWrapper.startSpan("aws.lambda.url", options); + } +} diff --git a/src/trace/tracer-wrapper.ts b/src/trace/tracer-wrapper.ts index 74b314d5..aa8eec82 100644 --- a/src/trace/tracer-wrapper.ts +++ b/src/trace/tracer-wrapper.ts @@ -16,6 +16,14 @@ export interface TraceOptions { childOf?: SpanContext; } +export interface SpanOptions { + childOf?: SpanContext; + tags?: { [key: string]: any }; + startTime?: number; + service?: string; + type?: string; +} + // TraceWrapper is used to remove dd-trace as a hard dependency from the npm package. // This lets a customer bring their own version of the tracer. export class TracerWrapper { @@ -61,6 +69,13 @@ export class TracerWrapper { return this.tracer.wrap(name, options, fn); } + public startSpan any>(name: string, options: TraceOptions): T | null { + if (!this.isTracerAvailable) { + return null; + } + return this.tracer.startSpan(name, options); + } + public traceContext(): TraceContext | undefined { if (!this.isTracerAvailable) { return; diff --git a/src/trace/trigger.spec.ts b/src/trace/trigger.spec.ts index 56ebe8f1..2b85ecc1 100644 --- a/src/trace/trigger.spec.ts +++ b/src/trace/trigger.spec.ts @@ -30,6 +30,15 @@ describe("parseEventSource", () => { }, file: "api-gateway-v2.json", }, + { + result: { + "function_trigger.event_source": "lambda-function-url", + "http.url": "a8hyhsshac.lambda-url.eu-south-1.amazonaws.com", + "http.url_details.path": "/", + "http.method": "GET", + }, + file: "lambda-function-urls.json", + }, { result: { "function_trigger.event_source": "application-load-balancer", @@ -146,7 +155,14 @@ describe("parseEventSource", () => { for (let response of responses) { const statusCode = extractHTTPStatusCodeTag(triggerTags, response.responseBody); // We should always return a status code for API Gateway and ALB - if (["api-gateway-v1.json", "api-gateway-v2.json", "application-load-balancer.json"].includes(event.file)) { + if ( + [ + "api-gateway-v1.json", + "api-gateway-v2.json", + "application-load-balancer.json", + "lambda-function-urls.json", + ].includes(event.file) + ) { expect(statusCode).toEqual(response.expectedStatusCode); } else { expect(statusCode).toBeUndefined(); diff --git a/src/trace/trigger.ts b/src/trace/trigger.ts index 8714fa3d..73bf6c6b 100644 --- a/src/trace/trigger.ts +++ b/src/trace/trigger.ts @@ -15,9 +15,23 @@ import { import * as eventType from "../utils/event-type-guards"; import { logError } from "../utils"; import { gunzipSync } from "zlib"; +type LambdaURLEvent = { + headers: { [name: string]: string | undefined }; + requestContext: { + domainName?: string | undefined; + http: { + method: string; + path: string; + }; + }; +}; function isHTTPTriggerEvent(eventSource: string | undefined) { - return eventSource === "api-gateway" || eventSource === "application-load-balancer"; + return ( + eventSource === "api-gateway" || + eventSource === "application-load-balancer" || + eventSource === "lambda-function-url" + ); } function getAWSPartitionByRegion(region: string) { @@ -72,55 +86,72 @@ function extractSQSEventARN(event: SQSEvent) { return event.Records[0].eventSourceARN; } +export enum eventSources { + apiGateway = "api-gateway", + applicationLoadBalancer = "application-load-balancer", + cloudFront = "cloudfront", + cloudWatchEvents = "cloudwatch-events", + cloudWatchLogs = "cloudwatch-logs", + // alb = "alb", + cloudWatch = "cloudwatch", + dynamoDB = "dynamodb", + kinesis = "kinesis", + lambdaUrl = "lambda-function-url", + s3 = "s3", + sns = "sns", + sqs = "sqs", +} /** * parseEventSource parses the triggering event to determine the source * Possible Returns: * api-gateway | application-load-balancer | cloudwatch-logs | * cloudwatch-events | cloudfront | dynamodb | kinesis | s3 | sns | sqs */ -export function parseEventSource(event: any) { - let eventSource: string | undefined; - +export function parseEventSource(event: any): eventSources | undefined { if (eventType.isAPIGatewayEvent(event) || eventType.isAPIGatewayEventV2(event)) { - eventSource = "api-gateway"; + return eventSources.apiGateway; + } + + if (eventType.isLambdaUrlEvent(event)) { + return eventSources.lambdaUrl; } if (eventType.isALBEvent(event)) { - eventSource = "application-load-balancer"; + return eventSources.applicationLoadBalancer; } if (eventType.isCloudWatchLogsEvent(event)) { - eventSource = "cloudwatch-logs"; + return eventSources.cloudWatchLogs; } if (eventType.isCloudWatchEvent(event)) { - eventSource = "cloudwatch-events"; + return eventSources.cloudWatchEvents; } if (eventType.isCloudFrontRequestEvent(event)) { - eventSource = "cloudfront"; + return eventSources.cloudFront; } if (eventType.isDynamoDBStreamEvent(event)) { - eventSource = "dynamodb"; + return eventSources.dynamoDB; } if (eventType.isKinesisStreamEvent(event)) { - eventSource = "kinesis"; + return eventSources.kinesis; } if (eventType.isS3Event(event)) { - eventSource = "s3"; + return eventSources.s3; } if (eventType.isSNSEvent(event)) { - eventSource = "sns"; + return eventSources.sns; } if (eventType.isSQSEvent(event)) { - eventSource = "sqs"; + return eventSources.sqs; } - return eventSource; + return undefined; } /** @@ -192,7 +223,7 @@ export function parseEventSourceARN(source: string | undefined, event: any, cont /** * extractHTTPTags extracts HTTP facet tags from the triggering event */ -function extractHTTPTags(event: APIGatewayEvent | APIGatewayProxyEventV2 | ALBEvent) { +function extractHTTPTags(event: APIGatewayEvent | APIGatewayProxyEventV2 | ALBEvent | LambdaURLEvent) { const httpTags: { [key: string]: string } = {}; if (eventType.isAPIGatewayEvent(event)) { @@ -227,6 +258,19 @@ function extractHTTPTags(event: APIGatewayEvent | APIGatewayProxyEventV2 | ALBEv } return httpTags; } + + if (eventType.isLambdaUrlEvent(event)) { + const requestContext = event.requestContext; + if (requestContext.domainName) { + httpTags["http.url"] = requestContext.domainName; + } + httpTags["http.url_details.path"] = requestContext.http.path; + httpTags["http.method"] = requestContext.http.method; + if (event.headers?.Referer) { + httpTags["http.referer"] = event.headers.Referer; + } + return httpTags; + } } /** diff --git a/src/utils/event-type-guards.ts b/src/utils/event-type-guards.ts index 46caeffd..b6affd5c 100644 --- a/src/utils/event-type-guards.ts +++ b/src/utils/event-type-guards.ts @@ -20,7 +20,18 @@ export function isAPIGatewayEvent(event: any): event is APIGatewayEvent { export function isAPIGatewayEventV2(event: any): event is APIGatewayProxyEventV2 { return ( - event.requestContext !== undefined && event.version === apiGatewayEventV2 && event.rawQueryString !== undefined + event.requestContext !== undefined && + event.version === apiGatewayEventV2 && + event.rawQueryString !== undefined && + !event.requestContext.domainName.includes("lambda-url") + ); +} + +export function isLambdaUrlEvent(event: any): boolean { + return ( + event.requestContext !== undefined && + event.requestContext.domainName && + event.requestContext.domainName.includes("lambda-url") ); }