@vicacha-devs/strapi-plugin-opentelemetry 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +21 -0
- package/README.md +110 -2
- package/dist/admin/index.d.ts +7 -0
- package/dist/admin/index.js +12 -0
- package/dist/admin/index.mjs +12 -0
- package/dist/admin/pluginId.d.ts +1 -0
- package/dist/server/config.d.ts +26 -0
- package/dist/server/documents.d.ts +12 -0
- package/dist/server/index.d.ts +22 -0
- package/dist/server/index.js +544 -0
- package/dist/server/index.mjs +544 -0
- package/dist/server/logger-hook.d.ts +12 -0
- package/dist/server/middleware.d.ts +20 -0
- package/dist/server/sanitize.d.ts +22 -0
- package/dist/server/sdk.d.ts +10 -0
- package/package.json +97 -3
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperties(exports, { __esModule: { value: true }, [Symbol.toStringTag]: { value: "Module" } });
|
|
3
|
+
const api = require("@opentelemetry/api");
|
|
4
|
+
const autoInstrumentationsNode = require("@opentelemetry/auto-instrumentations-node");
|
|
5
|
+
const exporterMetricsOtlpHttp = require("@opentelemetry/exporter-metrics-otlp-http");
|
|
6
|
+
const exporterTraceOtlpHttp = require("@opentelemetry/exporter-trace-otlp-http");
|
|
7
|
+
const resources = require("@opentelemetry/resources");
|
|
8
|
+
const sdkMetrics = require("@opentelemetry/sdk-metrics");
|
|
9
|
+
const sdkNode = require("@opentelemetry/sdk-node");
|
|
10
|
+
const sdkTraceBase = require("@opentelemetry/sdk-trace-base");
|
|
11
|
+
const semanticConventions = require("@opentelemetry/semantic-conventions");
|
|
12
|
+
function stripTrailingSlashes(value) {
|
|
13
|
+
let end = value.length;
|
|
14
|
+
while (end > 0 && value.charCodeAt(end - 1) === 47) end -= 1;
|
|
15
|
+
return value.slice(0, end);
|
|
16
|
+
}
|
|
17
|
+
const defaultConfig = {
|
|
18
|
+
enabled: true,
|
|
19
|
+
serviceName: "strapi",
|
|
20
|
+
serviceVersion: "1.0.0",
|
|
21
|
+
environment: process.env.NODE_ENV || "development",
|
|
22
|
+
otlpEndpoint: "",
|
|
23
|
+
otlpHeaders: {},
|
|
24
|
+
sampleRate: 1,
|
|
25
|
+
ignorePaths: ["/_health", "/favicon.ico"],
|
|
26
|
+
captureDocumentService: true,
|
|
27
|
+
consoleExporter: false,
|
|
28
|
+
metricExportIntervalMs: 6e4
|
|
29
|
+
};
|
|
30
|
+
function parseOtlpHeaders(raw) {
|
|
31
|
+
if (!raw || typeof raw !== "string") {
|
|
32
|
+
return {};
|
|
33
|
+
}
|
|
34
|
+
const headers = {};
|
|
35
|
+
for (const pair of raw.split(",")) {
|
|
36
|
+
const eqIdx = pair.indexOf("=");
|
|
37
|
+
if (eqIdx <= 0) continue;
|
|
38
|
+
const key = pair.slice(0, eqIdx).trim();
|
|
39
|
+
const value = pair.slice(eqIdx + 1).trim();
|
|
40
|
+
if (key) {
|
|
41
|
+
headers[key] = value;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return headers;
|
|
45
|
+
}
|
|
46
|
+
function resolveOtelConfig(rawConfig = {}) {
|
|
47
|
+
const envEndpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT?.trim() ?? "";
|
|
48
|
+
const envServiceName = process.env.OTEL_SERVICE_NAME?.trim() ?? "";
|
|
49
|
+
const envHeaders = parseOtlpHeaders(process.env.OTEL_EXPORTER_OTLP_HEADERS);
|
|
50
|
+
return {
|
|
51
|
+
...defaultConfig,
|
|
52
|
+
...rawConfig,
|
|
53
|
+
serviceName: rawConfig.serviceName || envServiceName || defaultConfig.serviceName,
|
|
54
|
+
otlpEndpoint: stripTrailingSlashes(rawConfig.otlpEndpoint || envEndpoint),
|
|
55
|
+
otlpHeaders: {
|
|
56
|
+
...envHeaders,
|
|
57
|
+
...rawConfig.otlpHeaders
|
|
58
|
+
},
|
|
59
|
+
ignorePaths: rawConfig.ignorePaths ?? defaultConfig.ignorePaths
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
function validateOtelConfig(pluginConfig) {
|
|
63
|
+
if (pluginConfig === null || typeof pluginConfig !== "object" || Array.isArray(pluginConfig)) {
|
|
64
|
+
throw new Error("OpenTelemetry plugin config must be an object");
|
|
65
|
+
}
|
|
66
|
+
const cfg = pluginConfig;
|
|
67
|
+
if (cfg.enabled !== void 0 && typeof cfg.enabled !== "boolean") {
|
|
68
|
+
throw new Error("OpenTelemetry plugin config.enabled must be a boolean");
|
|
69
|
+
}
|
|
70
|
+
if (cfg.serviceName !== void 0 && typeof cfg.serviceName !== "string") {
|
|
71
|
+
throw new Error("OpenTelemetry plugin config.serviceName must be a string");
|
|
72
|
+
}
|
|
73
|
+
if (cfg.serviceVersion !== void 0 && typeof cfg.serviceVersion !== "string") {
|
|
74
|
+
throw new Error(
|
|
75
|
+
"OpenTelemetry plugin config.serviceVersion must be a string"
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
if (cfg.environment !== void 0 && typeof cfg.environment !== "string") {
|
|
79
|
+
throw new Error("OpenTelemetry plugin config.environment must be a string");
|
|
80
|
+
}
|
|
81
|
+
if (cfg.otlpEndpoint !== void 0 && typeof cfg.otlpEndpoint !== "string") {
|
|
82
|
+
throw new Error(
|
|
83
|
+
"OpenTelemetry plugin config.otlpEndpoint must be a string"
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
if (cfg.otlpHeaders !== void 0 && (cfg.otlpHeaders === null || typeof cfg.otlpHeaders !== "object" || Array.isArray(cfg.otlpHeaders))) {
|
|
87
|
+
throw new Error(
|
|
88
|
+
"OpenTelemetry plugin config.otlpHeaders must be a key-value object"
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
if (cfg.sampleRate !== void 0 && (typeof cfg.sampleRate !== "number" || Number.isNaN(cfg.sampleRate) || cfg.sampleRate < 0 || cfg.sampleRate > 1)) {
|
|
92
|
+
throw new Error(
|
|
93
|
+
"OpenTelemetry plugin config.sampleRate must be a number between 0 and 1"
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
if (cfg.ignorePaths !== void 0 && (!Array.isArray(cfg.ignorePaths) || cfg.ignorePaths.some((p) => typeof p !== "string"))) {
|
|
97
|
+
throw new Error(
|
|
98
|
+
"OpenTelemetry plugin config.ignorePaths must be an array of strings"
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
if (cfg.captureDocumentService !== void 0 && typeof cfg.captureDocumentService !== "boolean") {
|
|
102
|
+
throw new Error(
|
|
103
|
+
"OpenTelemetry plugin config.captureDocumentService must be a boolean"
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
if (cfg.consoleExporter !== void 0 && typeof cfg.consoleExporter !== "boolean") {
|
|
107
|
+
throw new Error(
|
|
108
|
+
"OpenTelemetry plugin config.consoleExporter must be a boolean"
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
if (cfg.metricExportIntervalMs !== void 0 && (typeof cfg.metricExportIntervalMs !== "number" || cfg.metricExportIntervalMs < 1e3)) {
|
|
112
|
+
throw new Error(
|
|
113
|
+
"OpenTelemetry plugin config.metricExportIntervalMs must be a number >= 1000"
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const MAX_MESSAGE_LENGTH = 1e3;
|
|
118
|
+
const SENSITIVE_QUERY_KEYS = /(^|[?&])(token|access_token|refresh_token|id_token|api_key|apikey|key|secret|password|passwd|auth|signature|sig|code)=([^&\s]+)/gi;
|
|
119
|
+
const BEARER_PATTERN = /\b(Bearer|Basic)\s+[A-Z0-9\-._~+/]+=*/gi;
|
|
120
|
+
const EMAIL_PATTERN = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g;
|
|
121
|
+
const MAX_INPUT_LENGTH = MAX_MESSAGE_LENGTH * 4;
|
|
122
|
+
const isAlpha = (c) => c >= 65 && c <= 90 || c >= 97 && c <= 122;
|
|
123
|
+
const isSchemeChar = (c) => isAlpha(c) || c >= 48 && c <= 57 || c === 43 || c === 46 || c === 45;
|
|
124
|
+
const isWhitespace = (c) => c === 32 || c >= 9 && c <= 13 || c === 160 || c === 5760 || c >= 8192 && c <= 8202 || c === 8232 || c === 8233 || c === 8239 || c === 8287 || c === 12288 || c === 65279;
|
|
125
|
+
function redactUrlCredentials(input) {
|
|
126
|
+
let out = "";
|
|
127
|
+
let from = 0;
|
|
128
|
+
let pos = 0;
|
|
129
|
+
for (; ; ) {
|
|
130
|
+
const idx = input.indexOf("://", pos);
|
|
131
|
+
if (idx === -1) break;
|
|
132
|
+
let start = idx;
|
|
133
|
+
while (start > from && isSchemeChar(input.charCodeAt(start - 1))) start--;
|
|
134
|
+
while (start < idx && !isAlpha(input.charCodeAt(start))) start++;
|
|
135
|
+
let end = -1;
|
|
136
|
+
if (start < idx) {
|
|
137
|
+
let i = idx + 3;
|
|
138
|
+
const userStart = i;
|
|
139
|
+
while (i < input.length) {
|
|
140
|
+
const c = input.charCodeAt(i);
|
|
141
|
+
if (c === 58 || c === 64 || isWhitespace(c)) break;
|
|
142
|
+
i++;
|
|
143
|
+
}
|
|
144
|
+
if (i > userStart && input.charCodeAt(i) === 58) {
|
|
145
|
+
i++;
|
|
146
|
+
const passStart = i;
|
|
147
|
+
while (i < input.length) {
|
|
148
|
+
const c = input.charCodeAt(i);
|
|
149
|
+
if (c === 64 || isWhitespace(c)) break;
|
|
150
|
+
i++;
|
|
151
|
+
}
|
|
152
|
+
if (i > passStart && input.charCodeAt(i) === 64) end = i + 1;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (end === -1) {
|
|
156
|
+
pos = idx + 3;
|
|
157
|
+
} else {
|
|
158
|
+
out += `${input.slice(from, idx + 3)}[REDACTED]@`;
|
|
159
|
+
from = end;
|
|
160
|
+
pos = end;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return out + input.slice(from);
|
|
164
|
+
}
|
|
165
|
+
function sanitizeTelemetryText(input) {
|
|
166
|
+
if (!input) return "";
|
|
167
|
+
let sanitized = redactUrlCredentials(
|
|
168
|
+
input.slice(0, MAX_INPUT_LENGTH).replace(BEARER_PATTERN, "$1 [REDACTED]")
|
|
169
|
+
).replace(SENSITIVE_QUERY_KEYS, "$1$2=[REDACTED]").replace(EMAIL_PATTERN, "[REDACTED_EMAIL]");
|
|
170
|
+
if (sanitized.length > MAX_MESSAGE_LENGTH) {
|
|
171
|
+
sanitized = `${sanitized.slice(0, MAX_MESSAGE_LENGTH)}...[truncated]`;
|
|
172
|
+
}
|
|
173
|
+
return sanitized;
|
|
174
|
+
}
|
|
175
|
+
function sanitizeError(err) {
|
|
176
|
+
const raw = err instanceof Error ? err : new Error(String(err));
|
|
177
|
+
const safeMessage = sanitizeTelemetryText(raw.message);
|
|
178
|
+
const safeError = new Error(safeMessage);
|
|
179
|
+
safeError.name = raw.name || "Error";
|
|
180
|
+
if (raw.stack) {
|
|
181
|
+
safeError.stack = sanitizeTelemetryText(raw.stack);
|
|
182
|
+
}
|
|
183
|
+
return safeError;
|
|
184
|
+
}
|
|
185
|
+
function extractPluginName(input) {
|
|
186
|
+
if (input.uid?.startsWith("plugin::")) {
|
|
187
|
+
const rest = input.uid.slice("plugin::".length);
|
|
188
|
+
const dotIdx = rest.indexOf(".");
|
|
189
|
+
return dotIdx > 0 ? rest.slice(0, dotIdx) : rest || void 0;
|
|
190
|
+
}
|
|
191
|
+
if (input.route) {
|
|
192
|
+
const match = /^\/(?:api\/)?([a-z0-9-]+)(?:\/|$)/i.exec(input.route);
|
|
193
|
+
if (match?.[1] && match[1] !== "api" && match[1] !== "admin") {
|
|
194
|
+
return match[1];
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return void 0;
|
|
198
|
+
}
|
|
199
|
+
const TRACER_NAME$2 = "@vicacha-devs/strapi-plugin-opentelemetry";
|
|
200
|
+
function registerDocumentServiceInstrumentation(documents, config) {
|
|
201
|
+
if (!config.enabled || !config.captureDocumentService || !documents?.use) {
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const tracer = api.trace.getTracer(TRACER_NAME$2, config.serviceVersion);
|
|
205
|
+
documents.use(async (context, next) => {
|
|
206
|
+
const spanName = `strapi.document.${context.action} ${context.uid}`;
|
|
207
|
+
const pluginName = extractPluginName({ uid: context.uid });
|
|
208
|
+
return tracer.startActiveSpan(
|
|
209
|
+
spanName,
|
|
210
|
+
{
|
|
211
|
+
kind: api.SpanKind.INTERNAL,
|
|
212
|
+
attributes: {
|
|
213
|
+
"strapi.document.uid": context.uid,
|
|
214
|
+
"strapi.document.action": context.action,
|
|
215
|
+
...pluginName ? { "strapi.plugin": pluginName } : {}
|
|
216
|
+
}
|
|
217
|
+
},
|
|
218
|
+
async (span) => {
|
|
219
|
+
try {
|
|
220
|
+
const result = await next();
|
|
221
|
+
span.setStatus({ code: api.SpanStatusCode.OK });
|
|
222
|
+
return result;
|
|
223
|
+
} catch (err) {
|
|
224
|
+
const error = sanitizeError(err);
|
|
225
|
+
span.recordException(error);
|
|
226
|
+
span.setStatus({
|
|
227
|
+
code: api.SpanStatusCode.ERROR,
|
|
228
|
+
message: error.message
|
|
229
|
+
});
|
|
230
|
+
throw err;
|
|
231
|
+
} finally {
|
|
232
|
+
span.end();
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
);
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
const WRAPPED_SYMBOL = Symbol.for("vicacha.strapi.otel.logger.wrapped");
|
|
239
|
+
const TRACER_NAME$1 = "@vicacha-devs/strapi-plugin-opentelemetry";
|
|
240
|
+
function instrumentStrapiLogger(logger, config) {
|
|
241
|
+
if (!config.enabled || !logger || typeof logger.error !== "function") {
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
if (logger[WRAPPED_SYMBOL]) {
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
const originalError = logger.error.bind(logger);
|
|
248
|
+
const tracer = api.trace.getTracer(TRACER_NAME$1, config.serviceVersion);
|
|
249
|
+
const meter = api.metrics.getMeter(TRACER_NAME$1, config.serviceVersion);
|
|
250
|
+
const loggedErrorCounter = meter.createCounter("strapi.log.errors", {
|
|
251
|
+
description: "Count of errors logged via strapi.log.error"
|
|
252
|
+
});
|
|
253
|
+
logger[WRAPPED_SYMBOL] = true;
|
|
254
|
+
logger.error = (...args) => {
|
|
255
|
+
try {
|
|
256
|
+
const firstArg = args[0];
|
|
257
|
+
if (typeof firstArg === "string" && firstArg.startsWith("[opentelemetry]")) {
|
|
258
|
+
return originalError(...args);
|
|
259
|
+
}
|
|
260
|
+
const errorObj = args.find((arg) => arg instanceof Error);
|
|
261
|
+
const safeError = errorObj ? sanitizeError(errorObj) : new Error(
|
|
262
|
+
sanitizeTelemetryText(
|
|
263
|
+
args.map((a) => typeof a === "string" ? a : "").filter(Boolean).join(" ") || "Logged error"
|
|
264
|
+
)
|
|
265
|
+
);
|
|
266
|
+
loggedErrorCounter.add(1, { "error.type": safeError.name });
|
|
267
|
+
const activeSpan = api.trace.getActiveSpan();
|
|
268
|
+
if (activeSpan) {
|
|
269
|
+
activeSpan.recordException(safeError);
|
|
270
|
+
activeSpan.setStatus({
|
|
271
|
+
code: api.SpanStatusCode.ERROR,
|
|
272
|
+
message: safeError.message
|
|
273
|
+
});
|
|
274
|
+
} else {
|
|
275
|
+
tracer.startActiveSpan(
|
|
276
|
+
"strapi.log.error",
|
|
277
|
+
{ kind: api.SpanKind.INTERNAL },
|
|
278
|
+
(span) => {
|
|
279
|
+
span.recordException(safeError);
|
|
280
|
+
span.setStatus({
|
|
281
|
+
code: api.SpanStatusCode.ERROR,
|
|
282
|
+
message: safeError.message
|
|
283
|
+
});
|
|
284
|
+
span.end();
|
|
285
|
+
}
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
} catch {
|
|
289
|
+
}
|
|
290
|
+
return originalError(...args);
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
const TRACER_NAME = "@vicacha-devs/strapi-plugin-opentelemetry";
|
|
294
|
+
function createOtelRequestMiddleware(config, logger) {
|
|
295
|
+
const tracer = api.trace.getTracer(TRACER_NAME, config.serviceVersion);
|
|
296
|
+
const meter = api.metrics.getMeter(TRACER_NAME, config.serviceVersion);
|
|
297
|
+
const durationHistogram = meter.createHistogram(
|
|
298
|
+
"http.server.request.duration",
|
|
299
|
+
{
|
|
300
|
+
description: "Duration of Strapi HTTP server requests in milliseconds",
|
|
301
|
+
unit: "ms"
|
|
302
|
+
}
|
|
303
|
+
);
|
|
304
|
+
const errorCounter = meter.createCounter("http.server.errors", {
|
|
305
|
+
description: "Count of failed Strapi HTTP requests (5xx or unhandled)"
|
|
306
|
+
});
|
|
307
|
+
return async function otelRequestTracker(ctx, next) {
|
|
308
|
+
if (!config.enabled || config.ignorePaths.some((prefix) => ctx.path.startsWith(prefix))) {
|
|
309
|
+
await next();
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
const activeSpan = api.trace.getActiveSpan();
|
|
313
|
+
if (activeSpan) {
|
|
314
|
+
await executeWithSpan(activeSpan, ctx, next);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
await tracer.startActiveSpan(
|
|
318
|
+
`${ctx.method} ${ctx.path}`,
|
|
319
|
+
{ kind: api.SpanKind.SERVER },
|
|
320
|
+
async (span) => {
|
|
321
|
+
try {
|
|
322
|
+
await executeWithSpan(span, ctx, next);
|
|
323
|
+
} finally {
|
|
324
|
+
span.end();
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
);
|
|
328
|
+
};
|
|
329
|
+
async function executeWithSpan(span, ctx, next) {
|
|
330
|
+
const startMs = performance.now();
|
|
331
|
+
span.setAttributes({
|
|
332
|
+
"http.request.method": ctx.method,
|
|
333
|
+
"url.path": ctx.path
|
|
334
|
+
});
|
|
335
|
+
try {
|
|
336
|
+
await next();
|
|
337
|
+
const route = ctx._matchedRoute || ctx.path;
|
|
338
|
+
const status = ctx.status ?? 200;
|
|
339
|
+
const pluginName = extractPluginName({ route });
|
|
340
|
+
span.setAttributes({
|
|
341
|
+
"http.route": route,
|
|
342
|
+
"http.response.status_code": status,
|
|
343
|
+
...pluginName ? { "strapi.plugin": pluginName } : {}
|
|
344
|
+
});
|
|
345
|
+
attachUserContext(span, ctx);
|
|
346
|
+
if (status >= 500) {
|
|
347
|
+
span.setStatus({
|
|
348
|
+
code: api.SpanStatusCode.ERROR,
|
|
349
|
+
message: `HTTP ${status}`
|
|
350
|
+
});
|
|
351
|
+
errorCounter.add(1, {
|
|
352
|
+
"http.request.method": ctx.method,
|
|
353
|
+
"http.route": route,
|
|
354
|
+
"http.response.status_code": status,
|
|
355
|
+
...pluginName ? { "strapi.plugin": pluginName } : {}
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
durationHistogram.record(performance.now() - startMs, {
|
|
359
|
+
"http.request.method": ctx.method,
|
|
360
|
+
"http.route": route,
|
|
361
|
+
"http.response.status_code": status,
|
|
362
|
+
...pluginName ? { "strapi.plugin": pluginName } : {}
|
|
363
|
+
});
|
|
364
|
+
} catch (err) {
|
|
365
|
+
const error = sanitizeError(err);
|
|
366
|
+
const route = ctx._matchedRoute || ctx.path;
|
|
367
|
+
const pluginName = extractPluginName({ route });
|
|
368
|
+
span.recordException(error);
|
|
369
|
+
span.setStatus({
|
|
370
|
+
code: api.SpanStatusCode.ERROR,
|
|
371
|
+
message: error.message
|
|
372
|
+
});
|
|
373
|
+
span.setAttribute("http.route", route);
|
|
374
|
+
if (pluginName) {
|
|
375
|
+
span.setAttribute("strapi.plugin", pluginName);
|
|
376
|
+
}
|
|
377
|
+
attachUserContext(span, ctx);
|
|
378
|
+
errorCounter.add(1, {
|
|
379
|
+
"http.request.method": ctx.method,
|
|
380
|
+
"http.route": route,
|
|
381
|
+
"error.type": error.name,
|
|
382
|
+
...pluginName ? { "strapi.plugin": pluginName } : {}
|
|
383
|
+
});
|
|
384
|
+
durationHistogram.record(performance.now() - startMs, {
|
|
385
|
+
"http.request.method": ctx.method,
|
|
386
|
+
"http.route": route,
|
|
387
|
+
"error.type": error.name,
|
|
388
|
+
...pluginName ? { "strapi.plugin": pluginName } : {}
|
|
389
|
+
});
|
|
390
|
+
const spanCtx = span.spanContext();
|
|
391
|
+
logger?.error(
|
|
392
|
+
`[opentelemetry] Unhandled request error: ${error.message}`,
|
|
393
|
+
{
|
|
394
|
+
trace_id: spanCtx.traceId,
|
|
395
|
+
span_id: spanCtx.spanId,
|
|
396
|
+
method: ctx.method,
|
|
397
|
+
path: ctx.path,
|
|
398
|
+
...pluginName ? { plugin: pluginName } : {}
|
|
399
|
+
}
|
|
400
|
+
);
|
|
401
|
+
throw err;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
function attachUserContext(span, ctx) {
|
|
406
|
+
const user = ctx.state?.user;
|
|
407
|
+
if (!user) return;
|
|
408
|
+
const userId = user.documentId ?? user.id;
|
|
409
|
+
if (userId !== void 0 && userId !== null) {
|
|
410
|
+
span.setAttribute("enduser.id", String(userId));
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
const SDK_SYMBOL = Symbol.for("vicacha.strapi.otel.sdk");
|
|
414
|
+
const URL_ATTR_KEYS = ["url.full", "http.url", "url.query"];
|
|
415
|
+
class SanitizingSpanExporter {
|
|
416
|
+
constructor(inner) {
|
|
417
|
+
this.inner = inner;
|
|
418
|
+
}
|
|
419
|
+
export(spans, resultCallback) {
|
|
420
|
+
for (const span of spans) {
|
|
421
|
+
const attrs = span.attributes;
|
|
422
|
+
for (const key of URL_ATTR_KEYS) {
|
|
423
|
+
if (typeof attrs[key] === "string") {
|
|
424
|
+
attrs[key] = sanitizeTelemetryText(attrs[key]);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
this.inner.export(spans, resultCallback);
|
|
429
|
+
}
|
|
430
|
+
shutdown() {
|
|
431
|
+
return this.inner.shutdown();
|
|
432
|
+
}
|
|
433
|
+
forceFlush() {
|
|
434
|
+
return this.inner.forceFlush?.() ?? Promise.resolve();
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
function startOtelSdk(config) {
|
|
438
|
+
if (!config.enabled) {
|
|
439
|
+
return false;
|
|
440
|
+
}
|
|
441
|
+
const g = globalThis;
|
|
442
|
+
if (g[SDK_SYMBOL]) {
|
|
443
|
+
return false;
|
|
444
|
+
}
|
|
445
|
+
const resource = new resources.Resource({
|
|
446
|
+
[semanticConventions.ATTR_SERVICE_NAME]: config.serviceName,
|
|
447
|
+
[semanticConventions.ATTR_SERVICE_VERSION]: config.serviceVersion,
|
|
448
|
+
"deployment.environment.name": config.environment
|
|
449
|
+
});
|
|
450
|
+
const rawTraceExporter = config.otlpEndpoint ? new exporterTraceOtlpHttp.OTLPTraceExporter({
|
|
451
|
+
url: `${config.otlpEndpoint}/v1/traces`,
|
|
452
|
+
headers: config.otlpHeaders
|
|
453
|
+
}) : config.consoleExporter ? new sdkTraceBase.ConsoleSpanExporter() : void 0;
|
|
454
|
+
const traceExporter = rawTraceExporter ? new SanitizingSpanExporter(rawTraceExporter) : void 0;
|
|
455
|
+
const metricReader = config.otlpEndpoint ? new sdkMetrics.PeriodicExportingMetricReader({
|
|
456
|
+
exporter: new exporterMetricsOtlpHttp.OTLPMetricExporter({
|
|
457
|
+
url: `${config.otlpEndpoint}/v1/metrics`,
|
|
458
|
+
headers: config.otlpHeaders
|
|
459
|
+
}),
|
|
460
|
+
exportIntervalMillis: config.metricExportIntervalMs
|
|
461
|
+
}) : void 0;
|
|
462
|
+
const sdk = new sdkNode.NodeSDK({
|
|
463
|
+
resource,
|
|
464
|
+
sampler: new sdkTraceBase.ParentBasedSampler({
|
|
465
|
+
root: new sdkTraceBase.TraceIdRatioBasedSampler(config.sampleRate)
|
|
466
|
+
}),
|
|
467
|
+
...traceExporter ? { traceExporter } : {},
|
|
468
|
+
...metricReader ? { metricReader } : {},
|
|
469
|
+
instrumentations: [
|
|
470
|
+
autoInstrumentationsNode.getNodeAutoInstrumentations({
|
|
471
|
+
"@opentelemetry/instrumentation-fs": { enabled: false },
|
|
472
|
+
"@opentelemetry/instrumentation-http": {
|
|
473
|
+
ignoreIncomingRequestHook: (req) => {
|
|
474
|
+
const url = req.url ?? "";
|
|
475
|
+
return config.ignorePaths.some((prefix) => url.startsWith(prefix));
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
})
|
|
479
|
+
]
|
|
480
|
+
});
|
|
481
|
+
sdk.start();
|
|
482
|
+
g[SDK_SYMBOL] = sdk;
|
|
483
|
+
return true;
|
|
484
|
+
}
|
|
485
|
+
async function shutdownOtelSdk() {
|
|
486
|
+
const g = globalThis;
|
|
487
|
+
const sdk = g[SDK_SYMBOL];
|
|
488
|
+
if (!sdk) {
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
delete g[SDK_SYMBOL];
|
|
492
|
+
await sdk.shutdown();
|
|
493
|
+
}
|
|
494
|
+
const PLUGIN_ID = "opentelemetry";
|
|
495
|
+
function getPluginConfig(strapi) {
|
|
496
|
+
const raw = strapi.config.get(
|
|
497
|
+
`plugin::${PLUGIN_ID}`,
|
|
498
|
+
{}
|
|
499
|
+
);
|
|
500
|
+
return resolveOtelConfig(raw);
|
|
501
|
+
}
|
|
502
|
+
const index = {
|
|
503
|
+
register({ strapi }) {
|
|
504
|
+
const cfg = getPluginConfig(strapi);
|
|
505
|
+
if (!cfg.enabled) {
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
startOtelSdk(cfg);
|
|
509
|
+
instrumentStrapiLogger(
|
|
510
|
+
strapi.log,
|
|
511
|
+
cfg
|
|
512
|
+
);
|
|
513
|
+
const requestMiddleware = createOtelRequestMiddleware(cfg, strapi.log);
|
|
514
|
+
strapi.server.use(
|
|
515
|
+
requestMiddleware
|
|
516
|
+
);
|
|
517
|
+
registerDocumentServiceInstrumentation(
|
|
518
|
+
strapi.documents,
|
|
519
|
+
cfg
|
|
520
|
+
);
|
|
521
|
+
},
|
|
522
|
+
bootstrap({ strapi }) {
|
|
523
|
+
const cfg = getPluginConfig(strapi);
|
|
524
|
+
if (cfg.enabled) {
|
|
525
|
+
strapi.log.info(
|
|
526
|
+
`[opentelemetry] Active for service "${cfg.serviceName}" (${cfg.otlpEndpoint || (cfg.consoleExporter ? "console" : "no-exporter")})`
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
},
|
|
530
|
+
async destroy() {
|
|
531
|
+
await shutdownOtelSdk();
|
|
532
|
+
},
|
|
533
|
+
config: {
|
|
534
|
+
default: defaultConfig,
|
|
535
|
+
validator: validateOtelConfig
|
|
536
|
+
},
|
|
537
|
+
middlewares: {
|
|
538
|
+
tracker: (_config, { strapi }) => {
|
|
539
|
+
const cfg = getPluginConfig(strapi);
|
|
540
|
+
return createOtelRequestMiddleware(cfg, strapi.log);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
};
|
|
544
|
+
exports.default = index;
|