diff --git a/package.json b/package.json index 1cb5adc..82a19ad 100644 --- a/package.json +++ b/package.json @@ -30,5 +30,9 @@ "lambda-sample-events": "^1.0.1", "prettier": "^3.2.5", "semantic-release": "^23.0.8" + }, + "dependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/semantic-conventions": "^1.28.0" } } diff --git a/src/lib/ApiAdapter.js b/src/lib/ApiAdapter.js index 1e14822..1a738f7 100644 --- a/src/lib/ApiAdapter.js +++ b/src/lib/ApiAdapter.js @@ -1,5 +1,6 @@ const Response = require("./Response"); const Request = require("./Request"); +const OpenTelemetry = require("./OpenTelemetry"); class ApiAdapter { constructor(app, policies, errorConverter) { @@ -15,6 +16,8 @@ class ApiAdapter { return "Lambda is warm"; } + OpenTelemetry.addSpanRequestAttributes(event); + try { let input = event; @@ -60,9 +63,13 @@ class ApiAdapter { output = output.body; } - return new Response(output, this.statusCode, this.additionalHeaders); + const res = new Response(output, this.statusCode, this.additionalHeaders); + OpenTelemetry.addSpanResponseAttributes(event, res); + + return res; } catch (error) { console.error(error); + OpenTelemetry.addSpanErrorAttributes(event, error); return this.errorConverter.convert(error); } } diff --git a/src/lib/OpenTelemetry.js b/src/lib/OpenTelemetry.js new file mode 100644 index 0000000..d74c946 --- /dev/null +++ b/src/lib/OpenTelemetry.js @@ -0,0 +1,171 @@ +const { trace } = require("@opentelemetry/api"); +const { + ATTR_HTTP_ROUTE, + ATTR_URL_FULL, + ATTR_USER_AGENT_ORIGINAL, + ATTR_HTTP_REQUEST_METHOD, + ATTR_NETWORK_PROTOCOL_NAME, + ATTR_NETWORK_PROTOCOL_VERSION, +} = require("@opentelemetry/semantic-conventions"); +const { + ATTR_HTTP_USER_AGENT, + ATTR_HTTP_FLAVOR, +} = require("@opentelemetry/semantic-conventions/incubating"); + +const isApiGwEvent = (event) => { + return ( + event?.requestContext?.domainName != null && + event?.requestContext?.requestId != null + ); +}; + +const isSnsEvent = (event) => { + return event?.Records?.[0]?.EventSource === "aws:sns"; +}; + +const isSqsEvent = (event) => { + return event?.Records?.[0]?.eventSource === "aws:sqs"; +}; + +const isS3Event = (event) => { + return event?.Records?.[0]?.eventSource === "aws:s3"; +}; + +const isDDBEvent = (event) => { + return event?.Records?.[0]?.eventSource === "aws:dynamodb"; +}; + +const isCloudfrontEvent = (event) => { + return event?.Records?.[0]?.cf?.config?.distributionId != null; +}; + +const getFullUrl = (event) => { + if (!event.headers) return undefined; + function findAny(event, key1, key2) { + return event.headers[key1] ?? event.headers[key2]; + } + const host = findAny(event, "host", "Host"); + const proto = findAny(event, "x-forwarded-proto", "X-Forwarded-Proto"); + const port = findAny(event, "x-forwarded-port", "X-Forwarded-Port"); + if (!(proto && host && (event.path || event.rawPath))) { + return undefined; + } + let answer = proto + "://" + host; + if (port) { + answer += ":" + port; + } + answer += event.path ?? event.rawPath; + if (event.queryStringParameters) { + let first = true; + for (const key in event.queryStringParameters) { + answer += first ? "?" : "&"; + answer += encodeURIComponent(key); + answer += "="; + answer += encodeURIComponent(event.queryStringParameters[key]); + first = false; + } + } + return answer; +}; + +class OpenTelemetry { + static _getSpan() { + return trace.getActiveSpan(); + } + + static setSpanAttribute(key, value) { + const span = this._getSpan(); + if (span) span.setAttribute(key, value); + } + + static addSpanRequestAttributes(event) { + try { + const span = this._getSpan(); + if (!span) return; + + if (isApiGwEvent(event)) { + const fullUrl = getFullUrl(event); + span.setAttribute(ATTR_HTTP_ROUTE, event.routeKey?.split(" ")[1]); + fullUrl && span.setAttribute(ATTR_URL_FULL, fullUrl); + span.setAttribute( + ATTR_HTTP_REQUEST_METHOD, + event.requestContext?.http?.method, + ); + span.setAttribute( + ATTR_USER_AGENT_ORIGINAL, + event.requestContext?.http?.userAgent, + ); + span.setAttribute(ATTR_NETWORK_PROTOCOL_NAME, "http"); + span.setAttribute( + ATTR_NETWORK_PROTOCOL_VERSION, + event.requestContext?.http?.protocol?.split("/")?.[1], + ); + span.setAttribute("http.request.id", event.requestContext?.requestId); + span.setAttribute( + "http.request.header.content-type", + event.headers?.["content-type"], + ); + span.setAttribute("http.request.body_size", event.body?.length || 0); + span.setAttribute("url.path", event.rawPath); + span.setAttribute("url.query", event.rawQueryString); + if (event.requestContext?.authorizer?.jwt?.claims) { + const { claims } = event.requestContext.authorizer.jwt; + span.setAttribute("user.id", claims.sub || claims.id); + span.setAttribute("user.auth_method", "jwt"); + span.setAttribute( + "user.role", + claims.role || claims["cognito:groups"], + ); + if (claims.event_id?.includes("Parent=")) { + const parentTraceId = claims.event_id + .split("Parent=")?.[1] + ?.split(";")?.[0]; + span.setAttribute("user.auth_parent_trace_id", parentTraceId); + } + } + } + + // TODO: deal with other event types (S3, SQS, SNS, etc.) + } catch (e) { + console.debug("Error in addSpanRequestAttributes", e); + } + } + + static addSpanResponseAttributes(event, response) { + try { + const span = this._getSpan(); + if (!span) return; + + if (isApiGwEvent(event)) { + span.setAttribute("http.response.status_code", response.statusCode); + span.setAttribute( + "http.response.header.content-type", + response.headers?.["content-type"] || + response.headers?.["Content-Type"], + ); + span.setAttribute( + "http.response.body_size", + response.body?.length || 0, + ); + } + } catch (e) { + console.debug("Error in addSpanResponseAttributes", e); + } + } + + static addSpanErrorAttributes(event, error) { + try { + const span = this._getSpan(); + if (!span) return; + + span.setAttribute("error", true); + span.setAttribute("exception.message", error.message); + span.setAttribute("exception.type", error.name); + span.setAttribute("exception.stacktrace", error.stack); + } catch (e) { + console.debug("Error in addSpanErrorAttributes", e); + } + } +} + +module.exports = OpenTelemetry; diff --git a/yarn.lock b/yarn.lock index 7e81a16..e066c3b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -895,6 +895,16 @@ dependencies: "@octokit/openapi-types" "^22.2.0" +"@opentelemetry/api@^1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/api/-/api-1.9.0.tgz#d03eba68273dc0f7509e2a3d5cba21eae10379fe" + integrity sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg== + +"@opentelemetry/semantic-conventions@^1.28.0": + version "1.28.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz#337fb2bca0453d0726696e745f50064411f646d6" + integrity sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA== + "@pkgjs/parseargs@^0.11.0": version "0.11.0" resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" @@ -4947,7 +4957,7 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0": +"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -4965,15 +4975,6 @@ string-width@^4.1.0, string-width@^4.2.0: is-fullwidth-code-point "^3.0.0" strip-ansi "^6.0.0" -string-width@^4.2.3: - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - string-width@^5.0.1, string-width@^5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794" @@ -5018,7 +5019,7 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -5032,13 +5033,6 @@ strip-ansi@^6.0.0: dependencies: ansi-regex "^5.0.0" -strip-ansi@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - strip-ansi@^7.0.1: version "7.1.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" @@ -5475,16 +5469,7 @@ wordwrap@^1.0.0: resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - -wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==