@monocle.sh/adonisjs-agent 1.0.0-beta.10
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/README.md +133 -0
- package/dist/configure.d.mts +6 -0
- package/dist/configure.mjs +61 -0
- package/dist/decorators.d.mts +83 -0
- package/dist/decorators.mjs +110 -0
- package/dist/helpers.d.mts +49 -0
- package/dist/helpers.mjs +72 -0
- package/dist/index.d.mts +7 -0
- package/dist/index.mjs +7 -0
- package/dist/init.d.mts +8 -0
- package/dist/init.mjs +123 -0
- package/dist/monocle_middleware.d.mts +2 -0
- package/dist/monocle_middleware.mjs +7 -0
- package/dist/monocle_provider.d.mts +19 -0
- package/dist/monocle_provider.mjs +66 -0
- package/dist/src/cli_instrumentation.mjs +127 -0
- package/dist/src/context_lines.mjs +269 -0
- package/dist/src/define_config.d.mts +11 -0
- package/dist/src/define_config.mjs +72 -0
- package/dist/src/error_serializer.mjs +77 -0
- package/dist/src/exception_reporter.mjs +52 -0
- package/dist/src/host_metrics.mjs +27 -0
- package/dist/src/instrumentations/mail/instrumentation.mjs +158 -0
- package/dist/src/instrumentations/mail/types.mjs +21 -0
- package/dist/src/monocle.d.mts +48 -0
- package/dist/src/monocle.mjs +110 -0
- package/dist/src/types.d.mts +130 -0
- package/dist/stubs/config.stub +13 -0
- package/dist/stubs/main.mjs +8 -0
- package/dist/stubs/main.ts +4 -0
- package/dist/stubs/otel.stub +12 -0
- package/dist/types.d.mts +4 -0
- package/dist/types.mjs +1 -0
- package/package.json +69 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { ApplicationService } from "@adonisjs/core/types";
|
|
2
|
+
|
|
3
|
+
//#region providers/monocle_provider.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Stolen from @Adonisjs/otel to use another config filename. we need to expose this
|
|
6
|
+
* option from @adonis/otel so we can remove this file in the future.
|
|
7
|
+
*/
|
|
8
|
+
declare class OtelProvider {
|
|
9
|
+
#private;
|
|
10
|
+
protected app: ApplicationService;
|
|
11
|
+
constructor(app: ApplicationService);
|
|
12
|
+
register(): void;
|
|
13
|
+
/**
|
|
14
|
+
* Gracefully flush pending spans
|
|
15
|
+
*/
|
|
16
|
+
shutdown(): Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
//#endregion
|
|
19
|
+
export { OtelProvider as default };
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { ExceptionReporter, toHttpError } from "./src/exception_reporter.mjs";
|
|
2
|
+
import { getCurrentSpan } from "@adonisjs/otel/helpers";
|
|
3
|
+
import OtelMiddleware from "@adonisjs/otel/otel_middleware";
|
|
4
|
+
import { OtelManager } from "@adonisjs/otel";
|
|
5
|
+
import { ExceptionHandler } from "@adonisjs/core/http";
|
|
6
|
+
import { configProvider } from "@adonisjs/core";
|
|
7
|
+
|
|
8
|
+
//#region providers/monocle_provider.ts
|
|
9
|
+
/**
|
|
10
|
+
* Stolen from @Adonisjs/otel to use another config filename. we need to expose this
|
|
11
|
+
* option from @adonis/otel so we can remove this file in the future.
|
|
12
|
+
*/
|
|
13
|
+
var OtelProvider = class {
|
|
14
|
+
constructor(app) {
|
|
15
|
+
this.app = app;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Hook into ExceptionHandler to record exceptions in spans.
|
|
19
|
+
*
|
|
20
|
+
* We always record the exception on the span (for trace visibility), but we also
|
|
21
|
+
* add an attribute `monocle.exception.should_report` to indicate whether this
|
|
22
|
+
* exception should appear in the Exceptions dashboard.
|
|
23
|
+
*
|
|
24
|
+
* This respects the AdonisJS `ignoreExceptions`, `ignoreStatuses`, and `ignoreCodes`
|
|
25
|
+
* configuration from the ExceptionHandler.
|
|
26
|
+
*/
|
|
27
|
+
#registerExceptionHandler() {
|
|
28
|
+
const originalReport = ExceptionHandler.prototype.report;
|
|
29
|
+
const reporter = new ExceptionReporter();
|
|
30
|
+
ExceptionHandler.macro("report", async function(error, ctx) {
|
|
31
|
+
const span = getCurrentSpan();
|
|
32
|
+
if (span) {
|
|
33
|
+
const httpError = toHttpError(error);
|
|
34
|
+
const shouldReport = this.shouldReport(httpError);
|
|
35
|
+
await reporter.report({
|
|
36
|
+
span,
|
|
37
|
+
error,
|
|
38
|
+
shouldReport
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
return originalReport.call(this, error, ctx);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
register() {
|
|
45
|
+
this.#registerExceptionHandler();
|
|
46
|
+
this.#registerMiddleware();
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Register the OtelMiddleware as a singleton in the container
|
|
50
|
+
*/
|
|
51
|
+
#registerMiddleware() {
|
|
52
|
+
this.app.container.singleton(OtelMiddleware, async () => {
|
|
53
|
+
const otelConfigProvider = this.app.config.get("monocle", {});
|
|
54
|
+
return new OtelMiddleware({ userContext: (await configProvider.resolve(this.app, otelConfigProvider))?.userContext });
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Gracefully flush pending spans
|
|
59
|
+
*/
|
|
60
|
+
async shutdown() {
|
|
61
|
+
await OtelManager.getInstance()?.shutdown();
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
//#endregion
|
|
66
|
+
export { OtelProvider as default };
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { ExceptionReporter } from "./exception_reporter.mjs";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { SpanKind, context, trace } from "@opentelemetry/api";
|
|
4
|
+
|
|
5
|
+
//#region src/cli_instrumentation.ts
|
|
6
|
+
/**
|
|
7
|
+
* Default commands to exclude from tracing.
|
|
8
|
+
* These are typically scaffolding commands or long-running workers.
|
|
9
|
+
*/
|
|
10
|
+
const DEFAULT_EXCLUDE = [
|
|
11
|
+
"make:*",
|
|
12
|
+
"generate:*",
|
|
13
|
+
"queue:work",
|
|
14
|
+
"queue:listen"
|
|
15
|
+
];
|
|
16
|
+
/**
|
|
17
|
+
* Simple glob pattern matcher supporting only '*' wildcard
|
|
18
|
+
*/
|
|
19
|
+
function matchPattern(commandName, pattern) {
|
|
20
|
+
if (pattern === "*") return true;
|
|
21
|
+
if (!pattern.includes("*")) return commandName === pattern;
|
|
22
|
+
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
|
|
23
|
+
return new RegExp(`^${escaped.replace(/\*/g, ".*")}$`).test(commandName);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Extract command arguments from the instance
|
|
27
|
+
*/
|
|
28
|
+
function extractArgs(command) {
|
|
29
|
+
const argsDef = command.constructor.args;
|
|
30
|
+
if (!argsDef || !Array.isArray(argsDef)) return {};
|
|
31
|
+
const result = {};
|
|
32
|
+
for (const arg of argsDef) {
|
|
33
|
+
const value = command[arg.name];
|
|
34
|
+
if (value !== void 0 && value !== null) result[arg.argumentName] = String(value);
|
|
35
|
+
}
|
|
36
|
+
return result;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Extract command flags from the instance
|
|
40
|
+
*/
|
|
41
|
+
function extractFlags(command) {
|
|
42
|
+
const flagsDef = command.constructor.flags;
|
|
43
|
+
if (!flagsDef || !Array.isArray(flagsDef)) return {};
|
|
44
|
+
const result = {};
|
|
45
|
+
for (const flag of flagsDef) {
|
|
46
|
+
const value = command[flag.name];
|
|
47
|
+
if (value !== void 0 && value !== null && value !== false) result[flag.flagName] = String(value);
|
|
48
|
+
}
|
|
49
|
+
return result;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Check if a command should be traced based on include/exclude patterns
|
|
53
|
+
*/
|
|
54
|
+
function shouldTraceCommand(commandName, config) {
|
|
55
|
+
if ((config.exclude ?? DEFAULT_EXCLUDE).some((pattern) => matchPattern(commandName, pattern))) return false;
|
|
56
|
+
if (config.include && config.include.length > 0) return config.include.some((pattern) => matchPattern(commandName, pattern));
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Instrument AdonisJS Ace CLI commands with OpenTelemetry tracing.
|
|
61
|
+
*
|
|
62
|
+
* Uses monkey-patching of BaseCommand.prototype.exec to wrap command
|
|
63
|
+
* execution with an active OTEL span context. This ensures all child
|
|
64
|
+
* spans created during command execution are properly linked to the
|
|
65
|
+
* CLI command span.
|
|
66
|
+
*
|
|
67
|
+
* @param config - CLI tracing configuration
|
|
68
|
+
* @param appRoot - Application root directory path
|
|
69
|
+
*/
|
|
70
|
+
async function instrumentCliCommands(config, appRoot) {
|
|
71
|
+
const tracer = trace.getTracer("@monocle.sh/adonisjs-agent", "1.0.0");
|
|
72
|
+
const BaseCommand = (await import(createRequire(appRoot ? `file://${appRoot}/package.json` : import.meta.url).resolve("@adonisjs/core/ace"))).BaseCommand;
|
|
73
|
+
const originalExec = BaseCommand.prototype.exec;
|
|
74
|
+
BaseCommand.prototype.exec = async function() {
|
|
75
|
+
const commandName = this.constructor.commandName;
|
|
76
|
+
const commandDescription = this.constructor.description;
|
|
77
|
+
if (!shouldTraceCommand(commandName, config)) return originalExec.call(this);
|
|
78
|
+
const args = extractArgs(this);
|
|
79
|
+
const flags = extractFlags(this);
|
|
80
|
+
const attributes = {
|
|
81
|
+
"entry_point.type": "cli",
|
|
82
|
+
"cli.command.name": commandName,
|
|
83
|
+
"cli.command.description": commandDescription || ""
|
|
84
|
+
};
|
|
85
|
+
for (const [key, value] of Object.entries(args)) attributes[`cli.args.${key}`] = value;
|
|
86
|
+
for (const [key, value] of Object.entries(flags)) attributes[`cli.flags.${key}`] = value;
|
|
87
|
+
const span = tracer.startSpan(`cli ${commandName}`, {
|
|
88
|
+
kind: SpanKind.INTERNAL,
|
|
89
|
+
attributes
|
|
90
|
+
});
|
|
91
|
+
const ctx = trace.setSpan(context.active(), span);
|
|
92
|
+
/**
|
|
93
|
+
* CRITICAL: Execute within context.with()
|
|
94
|
+
*
|
|
95
|
+
* This is what makes child spans work! Without this wrapper:
|
|
96
|
+
* - Child spans would have no parent
|
|
97
|
+
* - All spans would be root spans with different traceIds
|
|
98
|
+
* - The trace would be fragmented
|
|
99
|
+
*
|
|
100
|
+
* context.with() sets our span as the "active" span for all async
|
|
101
|
+
* operations within the callback, enabling proper trace propagation.
|
|
102
|
+
*/
|
|
103
|
+
return context.with(ctx, async () => {
|
|
104
|
+
try {
|
|
105
|
+
const result = await originalExec.call(this);
|
|
106
|
+
if (this.error) await new ExceptionReporter().report({
|
|
107
|
+
span,
|
|
108
|
+
error: this.error,
|
|
109
|
+
shouldReport: true
|
|
110
|
+
});
|
|
111
|
+
return result;
|
|
112
|
+
} catch (error) {
|
|
113
|
+
await new ExceptionReporter().report({
|
|
114
|
+
span,
|
|
115
|
+
error,
|
|
116
|
+
shouldReport: true
|
|
117
|
+
});
|
|
118
|
+
throw error;
|
|
119
|
+
} finally {
|
|
120
|
+
span.end();
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
//#endregion
|
|
127
|
+
export { instrumentCliCommands };
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { MAX_CAUSE_DEPTH, getErrorCause, isErrorLike } from "./error_serializer.mjs";
|
|
2
|
+
import { createInterface } from "node:readline";
|
|
3
|
+
import { createReadStream } from "node:fs";
|
|
4
|
+
import { parseStack } from "error-stack-parser-es/lite";
|
|
5
|
+
|
|
6
|
+
//#region src/context_lines.ts
|
|
7
|
+
const DEFAULT_CONTEXT_LINES = 7;
|
|
8
|
+
const MAX_LINENO = 1e4;
|
|
9
|
+
const MAX_COLNO = 1e3;
|
|
10
|
+
const MAX_LINE_LENGTH = 200;
|
|
11
|
+
const FILE_CACHE_SIZE = 10;
|
|
12
|
+
const FAILED_CACHE_SIZE = 20;
|
|
13
|
+
/**
|
|
14
|
+
* Simple LRU cache implementation.
|
|
15
|
+
*/
|
|
16
|
+
var LRUCache = class {
|
|
17
|
+
#maxSize;
|
|
18
|
+
#cache = /* @__PURE__ */ new Map();
|
|
19
|
+
constructor(maxSize) {
|
|
20
|
+
this.#maxSize = maxSize;
|
|
21
|
+
}
|
|
22
|
+
get(key) {
|
|
23
|
+
const value = this.#cache.get(key);
|
|
24
|
+
if (value === void 0) return void 0;
|
|
25
|
+
this.#cache.delete(key);
|
|
26
|
+
this.#cache.set(key, value);
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
set(key, value) {
|
|
30
|
+
this.#cache.delete(key);
|
|
31
|
+
if (this.#cache.size >= this.#maxSize) {
|
|
32
|
+
const oldestKey = this.#cache.keys().next().value;
|
|
33
|
+
if (oldestKey !== void 0) this.#cache.delete(oldestKey);
|
|
34
|
+
}
|
|
35
|
+
this.#cache.set(key, value);
|
|
36
|
+
}
|
|
37
|
+
has(key) {
|
|
38
|
+
return this.#cache.has(key);
|
|
39
|
+
}
|
|
40
|
+
clear() {
|
|
41
|
+
this.#cache.clear();
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
const fileCache = new LRUCache(FILE_CACHE_SIZE);
|
|
45
|
+
const failedCache = new LRUCache(FAILED_CACHE_SIZE);
|
|
46
|
+
/**
|
|
47
|
+
* Normalize file path by stripping file:// protocol.
|
|
48
|
+
*/
|
|
49
|
+
function normalizeFilePath(path) {
|
|
50
|
+
if (path.startsWith("file://")) return path.slice(7);
|
|
51
|
+
return path;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Check if a file should be skipped for context extraction.
|
|
55
|
+
*/
|
|
56
|
+
function shouldSkipFile(path) {
|
|
57
|
+
if (path.startsWith("node:")) return true;
|
|
58
|
+
if (path.startsWith("data:")) return true;
|
|
59
|
+
if (path.endsWith(".min.js")) return true;
|
|
60
|
+
if (path.endsWith(".min.cjs")) return true;
|
|
61
|
+
if (path.endsWith(".min.mjs")) return true;
|
|
62
|
+
if (path.includes("/node_modules/")) return true;
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Check if a frame should be skipped based on line/column numbers.
|
|
67
|
+
*/
|
|
68
|
+
function shouldSkipFrame(frame) {
|
|
69
|
+
if (frame.line === void 0) return true;
|
|
70
|
+
if (frame.line > MAX_LINENO) return true;
|
|
71
|
+
if (frame.col !== void 0 && frame.col > MAX_COLNO) return true;
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Check if a frame is from the application code (not node_modules).
|
|
76
|
+
*/
|
|
77
|
+
function isInApp(filename) {
|
|
78
|
+
return !filename.includes("/node_modules/");
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Truncate long lines to prevent huge payloads.
|
|
82
|
+
*/
|
|
83
|
+
function snipLine(line) {
|
|
84
|
+
if (line.length <= MAX_LINE_LENGTH) return line;
|
|
85
|
+
return line.slice(0, MAX_LINE_LENGTH) + "...";
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Create context range around a line number.
|
|
89
|
+
*/
|
|
90
|
+
function makeContextRange(lineno, contextLines) {
|
|
91
|
+
return [Math.max(1, lineno - contextLines), lineno + contextLines];
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Merge overlapping line ranges for efficient file reading.
|
|
95
|
+
*/
|
|
96
|
+
function mergeRanges(ranges) {
|
|
97
|
+
if (ranges.length === 0) return [];
|
|
98
|
+
const sorted = [...ranges].sort((a, b) => a[0] - b[0]);
|
|
99
|
+
const merged = [sorted[0]];
|
|
100
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
101
|
+
const current = sorted[i];
|
|
102
|
+
const last = merged[merged.length - 1];
|
|
103
|
+
if (current[0] <= last[1] + 1) last[1] = Math.max(last[1], current[1]);
|
|
104
|
+
else merged.push(current);
|
|
105
|
+
}
|
|
106
|
+
return merged;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Read specific line ranges from a file.
|
|
110
|
+
* Follows Sentry's battle-tested patterns for stream handling.
|
|
111
|
+
*/
|
|
112
|
+
async function readFileLines(filepath, ranges) {
|
|
113
|
+
return new Promise((resolve) => {
|
|
114
|
+
const output = {};
|
|
115
|
+
let resolved = false;
|
|
116
|
+
const stream = createReadStream(filepath);
|
|
117
|
+
const lineReader = createInterface({ input: stream });
|
|
118
|
+
function destroyStreamAndResolve() {
|
|
119
|
+
if (resolved) return;
|
|
120
|
+
resolved = true;
|
|
121
|
+
stream.destroy();
|
|
122
|
+
resolve(output);
|
|
123
|
+
}
|
|
124
|
+
if (ranges.length === 0) {
|
|
125
|
+
destroyStreamAndResolve();
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
let lineNumber = 0;
|
|
129
|
+
let currentRangeIndex = 0;
|
|
130
|
+
let rangeStart = ranges[0][0];
|
|
131
|
+
let rangeEnd = ranges[0][1];
|
|
132
|
+
function onStreamError() {
|
|
133
|
+
failedCache.set(filepath, true);
|
|
134
|
+
lineReader.close();
|
|
135
|
+
lineReader.removeAllListeners();
|
|
136
|
+
destroyStreamAndResolve();
|
|
137
|
+
}
|
|
138
|
+
stream.on("error", onStreamError);
|
|
139
|
+
lineReader.on("error", onStreamError);
|
|
140
|
+
lineReader.on("close", destroyStreamAndResolve);
|
|
141
|
+
lineReader.on("line", (line) => {
|
|
142
|
+
try {
|
|
143
|
+
lineNumber++;
|
|
144
|
+
if (lineNumber < rangeStart) return;
|
|
145
|
+
output[lineNumber] = snipLine(line);
|
|
146
|
+
if (lineNumber >= rangeEnd) {
|
|
147
|
+
if (currentRangeIndex === ranges.length - 1) {
|
|
148
|
+
lineReader.close();
|
|
149
|
+
lineReader.removeAllListeners();
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
currentRangeIndex++;
|
|
153
|
+
const nextRange = ranges[currentRangeIndex];
|
|
154
|
+
rangeStart = nextRange[0];
|
|
155
|
+
rangeEnd = nextRange[1];
|
|
156
|
+
}
|
|
157
|
+
} catch {}
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Extract context lines for a stack trace.
|
|
163
|
+
*/
|
|
164
|
+
async function extractContextLines(stack, contextLines = DEFAULT_CONTEXT_LINES) {
|
|
165
|
+
const parsedFrames = parseStack(stack);
|
|
166
|
+
const framesToEnrich = [];
|
|
167
|
+
for (const frame of parsedFrames) {
|
|
168
|
+
if (!frame.file || !frame.line) continue;
|
|
169
|
+
const filepath = normalizeFilePath(frame.file);
|
|
170
|
+
if (shouldSkipFile(filepath)) continue;
|
|
171
|
+
if (shouldSkipFrame(frame)) continue;
|
|
172
|
+
const range = makeContextRange(frame.line, contextLines);
|
|
173
|
+
framesToEnrich.push({
|
|
174
|
+
frame,
|
|
175
|
+
filepath,
|
|
176
|
+
range
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
if (framesToEnrich.length === 0) return [];
|
|
180
|
+
const fileToRanges = /* @__PURE__ */ new Map();
|
|
181
|
+
for (const { filepath, range } of framesToEnrich) {
|
|
182
|
+
const existing = fileToRanges.get(filepath) || [];
|
|
183
|
+
existing.push(range);
|
|
184
|
+
fileToRanges.set(filepath, existing);
|
|
185
|
+
}
|
|
186
|
+
const readPromises = [];
|
|
187
|
+
for (const [filepath, ranges] of fileToRanges) {
|
|
188
|
+
if (failedCache.has(filepath)) continue;
|
|
189
|
+
const cachedContent = fileCache.get(filepath);
|
|
190
|
+
const mergedRanges = mergeRanges(ranges);
|
|
191
|
+
if (cachedContent && mergedRanges.every(([start, end]) => {
|
|
192
|
+
for (let i = start; i <= end; i++) if (cachedContent[i] === void 0) return false;
|
|
193
|
+
return true;
|
|
194
|
+
})) continue;
|
|
195
|
+
readPromises.push(readFileLines(filepath, mergedRanges).then((lines) => {
|
|
196
|
+
const existing = fileCache.get(filepath) || {};
|
|
197
|
+
fileCache.set(filepath, {
|
|
198
|
+
...existing,
|
|
199
|
+
...lines
|
|
200
|
+
});
|
|
201
|
+
}));
|
|
202
|
+
}
|
|
203
|
+
await Promise.all(readPromises).catch(() => {});
|
|
204
|
+
const enrichedFrames = [];
|
|
205
|
+
for (const { frame, filepath, range } of framesToEnrich) {
|
|
206
|
+
const fileContent = fileCache.get(filepath);
|
|
207
|
+
if (!fileContent) continue;
|
|
208
|
+
const [rangeStart, rangeEnd] = range;
|
|
209
|
+
const preContext = [];
|
|
210
|
+
const postContext = [];
|
|
211
|
+
for (let i = rangeStart; i < frame.line; i++) {
|
|
212
|
+
const line = fileContent[i];
|
|
213
|
+
if (line !== void 0) preContext.push(line);
|
|
214
|
+
}
|
|
215
|
+
const contextLine = fileContent[frame.line];
|
|
216
|
+
if (contextLine === void 0) continue;
|
|
217
|
+
for (let i = frame.line + 1; i <= rangeEnd; i++) {
|
|
218
|
+
const line = fileContent[i];
|
|
219
|
+
if (line !== void 0) postContext.push(line);
|
|
220
|
+
}
|
|
221
|
+
enrichedFrames.push({
|
|
222
|
+
filename: filepath,
|
|
223
|
+
lineno: frame.line,
|
|
224
|
+
colno: frame.col,
|
|
225
|
+
function: frame.function,
|
|
226
|
+
in_app: isInApp(filepath),
|
|
227
|
+
pre_context: preContext,
|
|
228
|
+
context_line: contextLine,
|
|
229
|
+
post_context: postContext
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
return enrichedFrames;
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Build a cause chain with context lines for each cause.
|
|
236
|
+
* This extracts context lines from each error's stack trace.
|
|
237
|
+
*/
|
|
238
|
+
async function buildCauseChainWithContext(error, contextLines = DEFAULT_CONTEXT_LINES) {
|
|
239
|
+
return _buildCauseChainWithContext(error, /* @__PURE__ */ new Set(), 0, contextLines);
|
|
240
|
+
}
|
|
241
|
+
async function _buildCauseChainWithContext(err, seen, depth, contextLines) {
|
|
242
|
+
if (!isErrorLike(err)) return [];
|
|
243
|
+
if (depth >= MAX_CAUSE_DEPTH) return [{
|
|
244
|
+
type: "Error",
|
|
245
|
+
message: "(max cause depth reached)"
|
|
246
|
+
}];
|
|
247
|
+
if (seen.has(err)) return [{
|
|
248
|
+
type: "Error",
|
|
249
|
+
message: "(circular reference detected)"
|
|
250
|
+
}];
|
|
251
|
+
seen.add(err);
|
|
252
|
+
let frames;
|
|
253
|
+
if (err.stack) try {
|
|
254
|
+
const extracted = await extractContextLines(err.stack, contextLines);
|
|
255
|
+
if (extracted.length > 0) frames = extracted;
|
|
256
|
+
} catch {}
|
|
257
|
+
const current = {
|
|
258
|
+
type: err.name || "Error",
|
|
259
|
+
message: err.message || "",
|
|
260
|
+
stack: err.stack,
|
|
261
|
+
frames
|
|
262
|
+
};
|
|
263
|
+
const cause = getErrorCause(err);
|
|
264
|
+
if (cause) return [current, ...await _buildCauseChainWithContext(cause, seen, depth + 1, contextLines)];
|
|
265
|
+
return [current];
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
//#endregion
|
|
269
|
+
export { buildCauseChainWithContext, extractContextLines };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { MonocleConfig } from "./types.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/define_config.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Define and validate Monocle agent configuration.
|
|
6
|
+
* Sets up environment variables for OTEL exporters to point to Monocle.
|
|
7
|
+
* Returns undefined if no API key is provided (telemetry will be disabled).
|
|
8
|
+
*/
|
|
9
|
+
declare function defineConfig(config: MonocleConfig): MonocleConfig | undefined;
|
|
10
|
+
//#endregion
|
|
11
|
+
export { defineConfig };
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
//#region src/define_config.ts
|
|
2
|
+
/**
|
|
3
|
+
* Extracts a header value from either ServerResponse (getHeader) or IncomingMessage (headers object).
|
|
4
|
+
*/
|
|
5
|
+
function getHeader(response, name) {
|
|
6
|
+
if ("getHeader" in response && typeof response.getHeader === "function") {
|
|
7
|
+
const value = response.getHeader(name);
|
|
8
|
+
if (typeof value === "string") return value;
|
|
9
|
+
}
|
|
10
|
+
if ("headers" in response && response.headers) {
|
|
11
|
+
const value = response.headers[name];
|
|
12
|
+
if (typeof value === "string") return value;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Detects the connection type based on response headers.
|
|
17
|
+
* Returns 'sse' for Server-Sent Events, 'websocket' for WebSocket upgrades,
|
|
18
|
+
* or undefined for standard HTTP connections.
|
|
19
|
+
*/
|
|
20
|
+
function detectConnectionType(response) {
|
|
21
|
+
if (getHeader(response, "content-type")?.includes("text/event-stream")) return "sse";
|
|
22
|
+
if (getHeader(response, "upgrade")?.toLowerCase() === "websocket") return "websocket";
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Creates a response hook that detects long-running HTTP connections
|
|
26
|
+
* (SSE and WebSocket) and adds the `http.connection_type` attribute.
|
|
27
|
+
*/
|
|
28
|
+
function createConnectionTypeHook(userHook) {
|
|
29
|
+
return (span, response) => {
|
|
30
|
+
const connectionType = detectConnectionType(response);
|
|
31
|
+
if (connectionType) span.setAttribute("http.connection_type", connectionType);
|
|
32
|
+
userHook?.(span, response);
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Extracts the user-defined response hook from HTTP instrumentation config.
|
|
37
|
+
*/
|
|
38
|
+
function extractUserResponseHook(httpConfig) {
|
|
39
|
+
if (typeof httpConfig !== "object" || httpConfig === null) return;
|
|
40
|
+
if (!("responseHook" in httpConfig)) return;
|
|
41
|
+
return httpConfig.responseHook;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Define and validate Monocle agent configuration.
|
|
45
|
+
* Sets up environment variables for OTEL exporters to point to Monocle.
|
|
46
|
+
* Returns undefined if no API key is provided (telemetry will be disabled).
|
|
47
|
+
*/
|
|
48
|
+
function defineConfig(config) {
|
|
49
|
+
if (!config.apiKey) return;
|
|
50
|
+
const endpoint = config.endpoint || "https://ingest.monocle.sh";
|
|
51
|
+
const environment = config.environment || process.env.NODE_ENV || "development";
|
|
52
|
+
process.env.OTEL_EXPORTER_OTLP_ENDPOINT = endpoint;
|
|
53
|
+
process.env.OTEL_EXPORTER_OTLP_HEADERS = `x-api-key=${config.apiKey},x-monocle-env=${environment}`;
|
|
54
|
+
const httpConfig = config.instrumentations?.["@opentelemetry/instrumentation-http"];
|
|
55
|
+
const userResponseHook = extractUserResponseHook(httpConfig);
|
|
56
|
+
const instrumentations = {
|
|
57
|
+
...config.instrumentations,
|
|
58
|
+
"@opentelemetry/instrumentation-http": {
|
|
59
|
+
...typeof httpConfig === "object" && httpConfig !== null ? httpConfig : {},
|
|
60
|
+
responseHook: createConnectionTypeHook(userResponseHook)
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
return {
|
|
64
|
+
...config,
|
|
65
|
+
endpoint,
|
|
66
|
+
environment,
|
|
67
|
+
instrumentations
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
//#endregion
|
|
72
|
+
export { defineConfig };
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
//#region src/error_serializer.ts
|
|
2
|
+
/**
|
|
3
|
+
* Maximum number of causes to capture (prevents infinite loops).
|
|
4
|
+
*/
|
|
5
|
+
const MAX_CAUSE_DEPTH = 5;
|
|
6
|
+
/**
|
|
7
|
+
* Check if a value is error-like (has a message property).
|
|
8
|
+
*/
|
|
9
|
+
function isErrorLike(err) {
|
|
10
|
+
return err != null && typeof err.message === "string";
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Safely extract the cause from an error.
|
|
14
|
+
* Handles both standard Error.cause and VError-style cause() functions.
|
|
15
|
+
*/
|
|
16
|
+
function getErrorCause(err) {
|
|
17
|
+
if (!err) return void 0;
|
|
18
|
+
const cause = err.cause;
|
|
19
|
+
if (typeof cause === "function") {
|
|
20
|
+
const causeResult = cause();
|
|
21
|
+
return isErrorLike(causeResult) ? causeResult : void 0;
|
|
22
|
+
}
|
|
23
|
+
return isErrorLike(cause) ? cause : void 0;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Build a combined stack trace from an error and all its causes.
|
|
27
|
+
* Format:
|
|
28
|
+
* Error: Top-level message
|
|
29
|
+
* at file.js:10:5
|
|
30
|
+
* caused by: Error: Mid-level message
|
|
31
|
+
* at file.js:20:3
|
|
32
|
+
*/
|
|
33
|
+
function stackWithCauses(err) {
|
|
34
|
+
return _stackWithCauses(err, /* @__PURE__ */ new Set());
|
|
35
|
+
}
|
|
36
|
+
function _stackWithCauses(err, seen) {
|
|
37
|
+
if (!isErrorLike(err)) return "";
|
|
38
|
+
const stack = err.stack || "";
|
|
39
|
+
if (seen.has(err)) return stack + "\ncauses have become circular...";
|
|
40
|
+
const cause = getErrorCause(err);
|
|
41
|
+
if (cause) {
|
|
42
|
+
seen.add(err);
|
|
43
|
+
return stack + "\ncaused by: " + _stackWithCauses(cause, seen);
|
|
44
|
+
}
|
|
45
|
+
return stack;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Build a structured cause chain from an error.
|
|
49
|
+
* Returns an array of { type, message, stack } objects.
|
|
50
|
+
* Limited to MAX_CAUSE_DEPTH to prevent infinite loops.
|
|
51
|
+
*/
|
|
52
|
+
function buildCauseChain(err) {
|
|
53
|
+
return _buildCauseChain(err, /* @__PURE__ */ new Set(), 0);
|
|
54
|
+
}
|
|
55
|
+
function _buildCauseChain(err, seen, depth) {
|
|
56
|
+
if (!isErrorLike(err)) return [];
|
|
57
|
+
if (depth >= MAX_CAUSE_DEPTH) return [{
|
|
58
|
+
type: "Error",
|
|
59
|
+
message: "(max cause depth reached)"
|
|
60
|
+
}];
|
|
61
|
+
if (seen.has(err)) return [{
|
|
62
|
+
type: "Error",
|
|
63
|
+
message: "(circular reference detected)"
|
|
64
|
+
}];
|
|
65
|
+
seen.add(err);
|
|
66
|
+
const current = {
|
|
67
|
+
type: err.name || "Error",
|
|
68
|
+
message: err.message || "",
|
|
69
|
+
stack: err.stack
|
|
70
|
+
};
|
|
71
|
+
const cause = getErrorCause(err);
|
|
72
|
+
if (cause) return [current, ..._buildCauseChain(cause, seen, depth + 1)];
|
|
73
|
+
return [current];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
//#endregion
|
|
77
|
+
export { MAX_CAUSE_DEPTH, buildCauseChain, getErrorCause, isErrorLike, stackWithCauses };
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { buildCauseChain, stackWithCauses } from "./error_serializer.mjs";
|
|
2
|
+
import { buildCauseChainWithContext } from "./context_lines.mjs";
|
|
3
|
+
import { SpanStatusCode } from "@opentelemetry/api";
|
|
4
|
+
import is from "@sindresorhus/is";
|
|
5
|
+
|
|
6
|
+
//#region src/exception_reporter.ts
|
|
7
|
+
const DEFAULT_CONTEXT_LINES = 7;
|
|
8
|
+
/**
|
|
9
|
+
* Convert any unknown error to an HttpError-like object.
|
|
10
|
+
*/
|
|
11
|
+
function toHttpError(error) {
|
|
12
|
+
const httpError = is.object(error) ? error : new Error(String(error));
|
|
13
|
+
if (!httpError.message) httpError.message = "Internal server error";
|
|
14
|
+
if (!httpError.status) httpError.status = 500;
|
|
15
|
+
return httpError;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Reports exceptions to OpenTelemetry spans with context lines and cause chains.
|
|
19
|
+
*/
|
|
20
|
+
var ExceptionReporter = class {
|
|
21
|
+
/**
|
|
22
|
+
* Report an exception on the given span.
|
|
23
|
+
*/
|
|
24
|
+
async report(options) {
|
|
25
|
+
const { span, shouldReport, contextLines = DEFAULT_CONTEXT_LINES } = options;
|
|
26
|
+
const error = toHttpError(options.error);
|
|
27
|
+
span.setStatus({
|
|
28
|
+
code: SpanStatusCode.ERROR,
|
|
29
|
+
message: error.message
|
|
30
|
+
});
|
|
31
|
+
span.setAttribute("monocle.exception.should_report", shouldReport);
|
|
32
|
+
const causeChain = shouldReport ? await buildCauseChainWithContext(error, contextLines) : buildCauseChain(error);
|
|
33
|
+
const attributes = this.#buildAttributes(error, causeChain);
|
|
34
|
+
span.addEvent("exception", attributes);
|
|
35
|
+
}
|
|
36
|
+
#buildAttributes(error, causeChain) {
|
|
37
|
+
const hasCauses = causeChain.length > 1;
|
|
38
|
+
const attributes = {
|
|
39
|
+
"exception.type": error.name || "Error",
|
|
40
|
+
"exception.message": error.message || "",
|
|
41
|
+
"exception.stacktrace": hasCauses ? stackWithCauses(error) : error.stack || ""
|
|
42
|
+
};
|
|
43
|
+
const actualCauses = causeChain.slice(1);
|
|
44
|
+
if (actualCauses.length > 0) attributes["monocle.exception.cause_chain"] = JSON.stringify(actualCauses);
|
|
45
|
+
const mainFrames = causeChain[0]?.frames;
|
|
46
|
+
if (mainFrames && mainFrames.length > 0) attributes["monocle.exception.frames"] = JSON.stringify(mainFrames);
|
|
47
|
+
return attributes;
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
//#endregion
|
|
52
|
+
export { ExceptionReporter, toHttpError };
|