Skip to content

Commit e2e4da3

Browse files
committed
feat: add OpenTelemetry support
1 parent 239db11 commit e2e4da3

File tree

4 files changed

+196
-29
lines changed

4 files changed

+196
-29
lines changed

package.json

+4
Original file line numberDiff line numberDiff line change
@@ -30,5 +30,9 @@
3030
"lambda-sample-events": "^1.0.1",
3131
"prettier": "^3.2.5",
3232
"semantic-release": "^23.0.8"
33+
},
34+
"dependencies": {
35+
"@opentelemetry/api": "^1.9.0",
36+
"@opentelemetry/semantic-conventions": "^1.28.0"
3337
}
3438
}

src/lib/ApiAdapter.js

+8-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
const Response = require("./Response");
22
const Request = require("./Request");
3+
const OpenTelemetry = require("./OpenTelemetry");
34

45
class ApiAdapter {
56
constructor(app, policies, errorConverter) {
@@ -15,6 +16,8 @@ class ApiAdapter {
1516
return "Lambda is warm";
1617
}
1718

19+
OpenTelemetry.addSpanRequestAttributes(event);
20+
1821
try {
1922
let input = event;
2023

@@ -60,9 +63,13 @@ class ApiAdapter {
6063
output = output.body;
6164
}
6265

63-
return new Response(output, this.statusCode, this.additionalHeaders);
66+
const res = new Response(output, this.statusCode, this.additionalHeaders);
67+
OpenTelemetry.addSpanResponseAttributes(event, res);
68+
69+
return res;
6470
} catch (error) {
6571
console.error(error);
72+
OpenTelemetry.addSpanErrorAttributes(event, error);
6673
return this.errorConverter.convert(error);
6774
}
6875
}

src/lib/OpenTelemetry.js

+171
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
const { trace } = require("@opentelemetry/api");
2+
const {
3+
ATTR_HTTP_ROUTE,
4+
ATTR_URL_FULL,
5+
ATTR_USER_AGENT_ORIGINAL,
6+
ATTR_HTTP_REQUEST_METHOD,
7+
ATTR_NETWORK_PROTOCOL_NAME,
8+
ATTR_NETWORK_PROTOCOL_VERSION,
9+
} = require("@opentelemetry/semantic-conventions");
10+
const {
11+
ATTR_HTTP_USER_AGENT,
12+
ATTR_HTTP_FLAVOR,
13+
} = require("@opentelemetry/semantic-conventions/incubating");
14+
15+
const isApiGwEvent = (event) => {
16+
return (
17+
event?.requestContext?.domainName != null &&
18+
event?.requestContext?.requestId != null
19+
);
20+
};
21+
22+
const isSnsEvent = (event) => {
23+
return event?.Records?.[0]?.EventSource === "aws:sns";
24+
};
25+
26+
const isSqsEvent = (event) => {
27+
return event?.Records?.[0]?.eventSource === "aws:sqs";
28+
};
29+
30+
const isS3Event = (event) => {
31+
return event?.Records?.[0]?.eventSource === "aws:s3";
32+
};
33+
34+
const isDDBEvent = (event) => {
35+
return event?.Records?.[0]?.eventSource === "aws:dynamodb";
36+
};
37+
38+
const isCloudfrontEvent = (event) => {
39+
return event?.Records?.[0]?.cf?.config?.distributionId != null;
40+
};
41+
42+
const getFullUrl = (event) => {
43+
if (!event.headers) return undefined;
44+
function findAny(event, key1, key2) {
45+
return event.headers[key1] ?? event.headers[key2];
46+
}
47+
const host = findAny(event, "host", "Host");
48+
const proto = findAny(event, "x-forwarded-proto", "X-Forwarded-Proto");
49+
const port = findAny(event, "x-forwarded-port", "X-Forwarded-Port");
50+
if (!(proto && host && (event.path || event.rawPath))) {
51+
return undefined;
52+
}
53+
let answer = proto + "://" + host;
54+
if (port) {
55+
answer += ":" + port;
56+
}
57+
answer += event.path ?? event.rawPath;
58+
if (event.queryStringParameters) {
59+
let first = true;
60+
for (const key in event.queryStringParameters) {
61+
answer += first ? "?" : "&";
62+
answer += encodeURIComponent(key);
63+
answer += "=";
64+
answer += encodeURIComponent(event.queryStringParameters[key]);
65+
first = false;
66+
}
67+
}
68+
return answer;
69+
};
70+
71+
class OpenTelemetry {
72+
static _getSpan() {
73+
return trace.getActiveSpan();
74+
}
75+
76+
static setSpanAttribute(key, value) {
77+
const span = this._getSpan();
78+
if (span) span.setAttribute(key, value);
79+
}
80+
81+
static addSpanRequestAttributes(event) {
82+
try {
83+
const span = this._getSpan();
84+
if (!span) return;
85+
86+
if (isApiGwEvent(event)) {
87+
const fullUrl = getFullUrl(event);
88+
span.setAttribute(ATTR_HTTP_ROUTE, event.routeKey?.split(" ")[1]);
89+
fullUrl && span.setAttribute(ATTR_URL_FULL, fullUrl);
90+
span.setAttribute(
91+
ATTR_HTTP_REQUEST_METHOD,
92+
event.requestContext?.http?.method,
93+
);
94+
span.setAttribute(
95+
ATTR_USER_AGENT_ORIGINAL,
96+
event.requestContext?.http?.userAgent,
97+
);
98+
span.setAttribute(ATTR_NETWORK_PROTOCOL_NAME, "http");
99+
span.setAttribute(
100+
ATTR_NETWORK_PROTOCOL_VERSION,
101+
event.requestContext?.http?.protocol?.split("/")?.[1],
102+
);
103+
span.setAttribute("http.request.id", event.requestContext?.requestId);
104+
span.setAttribute(
105+
"http.request.header.content-type",
106+
event.headers?.["content-type"],
107+
);
108+
span.setAttribute("http.request.body_size", event.body?.length || 0);
109+
span.setAttribute("url.path", event.rawPath);
110+
span.setAttribute("url.query", event.rawQueryString);
111+
if (event.requestContext?.authorizer?.jwt?.claims) {
112+
const { claims } = event.requestContext.authorizer.jwt;
113+
span.setAttribute("user.id", claims.sub || claims.id);
114+
span.setAttribute("user.auth_method", "jwt");
115+
span.setAttribute(
116+
"user.role",
117+
claims.role || claims["cognito:groups"],
118+
);
119+
if (claims.event_id?.includes("Parent=")) {
120+
const parentTraceId = claims.event_id
121+
.split("Parent=")?.[1]
122+
?.split(";")?.[0];
123+
span.setAttribute("user.auth_parent_trace_id", parentTraceId);
124+
}
125+
}
126+
}
127+
128+
// TODO: deal with other event types (S3, SQS, SNS, etc.)
129+
} catch (e) {
130+
console.debug("Error in addSpanRequestAttributes", e);
131+
}
132+
}
133+
134+
static addSpanResponseAttributes(event, response) {
135+
try {
136+
const span = this._getSpan();
137+
if (!span) return;
138+
139+
if (isApiGwEvent(event)) {
140+
span.setAttribute("http.response.status_code", response.statusCode);
141+
span.setAttribute(
142+
"http.response.header.content-type",
143+
response.headers?.["content-type"] ||
144+
response.headers?.["Content-Type"],
145+
);
146+
span.setAttribute(
147+
"http.response.body_size",
148+
response.body?.length || 0,
149+
);
150+
}
151+
} catch (e) {
152+
console.debug("Error in addSpanResponseAttributes", e);
153+
}
154+
}
155+
156+
static addSpanErrorAttributes(event, error) {
157+
try {
158+
const span = this._getSpan();
159+
if (!span) return;
160+
161+
span.setAttribute("error", true);
162+
span.setAttribute("exception.message", error.message);
163+
span.setAttribute("exception.type", error.name);
164+
span.setAttribute("exception.stacktrace", error.stack);
165+
} catch (e) {
166+
console.debug("Error in addSpanErrorAttributes", e);
167+
}
168+
}
169+
}
170+
171+
module.exports = OpenTelemetry;

yarn.lock

+13-28
Original file line numberDiff line numberDiff line change
@@ -895,6 +895,16 @@
895895
dependencies:
896896
"@octokit/openapi-types" "^22.2.0"
897897

898+
"@opentelemetry/api@^1.9.0":
899+
version "1.9.0"
900+
resolved "https://registry.yarnpkg.com/@opentelemetry/api/-/api-1.9.0.tgz#d03eba68273dc0f7509e2a3d5cba21eae10379fe"
901+
integrity sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==
902+
903+
"@opentelemetry/semantic-conventions@^1.28.0":
904+
version "1.28.0"
905+
resolved "https://registry.yarnpkg.com/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz#337fb2bca0453d0726696e745f50064411f646d6"
906+
integrity sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==
907+
898908
"@pkgjs/parseargs@^0.11.0":
899909
version "0.11.0"
900910
resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33"
@@ -4947,7 +4957,7 @@ string-length@^4.0.1:
49474957
char-regex "^1.0.2"
49484958
strip-ansi "^6.0.0"
49494959

4950-
"string-width-cjs@npm:string-width@^4.2.0":
4960+
"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.2.3:
49514961
version "4.2.3"
49524962
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
49534963
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@@ -4965,15 +4975,6 @@ string-width@^4.1.0, string-width@^4.2.0:
49654975
is-fullwidth-code-point "^3.0.0"
49664976
strip-ansi "^6.0.0"
49674977

4968-
string-width@^4.2.3:
4969-
version "4.2.3"
4970-
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
4971-
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
4972-
dependencies:
4973-
emoji-regex "^8.0.0"
4974-
is-fullwidth-code-point "^3.0.0"
4975-
strip-ansi "^6.0.1"
4976-
49774978
string-width@^5.0.1, string-width@^5.1.2:
49784979
version "5.1.2"
49794980
resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794"
@@ -5018,7 +5019,7 @@ string_decoder@~1.1.1:
50185019
dependencies:
50195020
safe-buffer "~5.1.0"
50205021

5021-
"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
5022+
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.1:
50225023
version "6.0.1"
50235024
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
50245025
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
@@ -5032,13 +5033,6 @@ strip-ansi@^6.0.0:
50325033
dependencies:
50335034
ansi-regex "^5.0.0"
50345035

5035-
strip-ansi@^6.0.1:
5036-
version "6.0.1"
5037-
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
5038-
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
5039-
dependencies:
5040-
ansi-regex "^5.0.1"
5041-
50425036
strip-ansi@^7.0.1:
50435037
version "7.1.0"
50445038
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45"
@@ -5475,16 +5469,7 @@ wordwrap@^1.0.0:
54755469
resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb"
54765470
integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==
54775471

5478-
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
5479-
version "7.0.0"
5480-
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
5481-
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
5482-
dependencies:
5483-
ansi-styles "^4.0.0"
5484-
string-width "^4.1.0"
5485-
strip-ansi "^6.0.0"
5486-
5487-
wrap-ansi@^7.0.0:
5472+
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
54885473
version "7.0.0"
54895474
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
54905475
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==

0 commit comments

Comments
 (0)