@monocle.sh/adonisjs-agent 1.0.0-beta.4 → 1.0.0-beta.5

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.
@@ -1,4 +1,4 @@
1
- import { SpanStatusCode } from "@opentelemetry/api";
1
+ import { recordExceptionWithContext } from "./src/context_lines.mjs";
2
2
  import { getCurrentSpan } from "@adonisjs/otel/helpers";
3
3
  import OtelMiddleware from "@adonisjs/otel/otel_middleware";
4
4
  import { OtelManager } from "@adonisjs/otel";
@@ -29,14 +29,12 @@ var OtelProvider = class {
29
29
  ExceptionHandler.macro("report", async function(error, ctx) {
30
30
  const span = getCurrentSpan();
31
31
  if (span && error instanceof Error) {
32
- span.recordException(error);
33
- span.setStatus({
34
- code: SpanStatusCode.ERROR,
35
- message: error.message
36
- });
37
32
  const httpError = this.toHttpError(error);
38
- const shouldReport = this.shouldReport(httpError);
39
- span.setAttribute("monocle.exception.should_report", shouldReport);
33
+ await recordExceptionWithContext({
34
+ span,
35
+ error,
36
+ shouldReport: this.shouldReport(httpError)
37
+ });
40
38
  }
41
39
  return originalReport.call(this, error, ctx);
42
40
  });
@@ -1,5 +1,6 @@
1
+ import { recordExceptionWithContext } from "./context_lines.mjs";
1
2
  import { createRequire } from "node:module";
2
- import { SpanKind, SpanStatusCode, context, trace } from "@opentelemetry/api";
3
+ import { SpanKind, context, trace } from "@opentelemetry/api";
3
4
 
4
5
  //#region src/cli_instrumentation.ts
5
6
  /**
@@ -71,13 +72,10 @@ async function instrumentCliCommands(config, appRoot) {
71
72
  try {
72
73
  return await originalExec.call(this);
73
74
  } catch (error) {
74
- if (error instanceof Error) {
75
- span.recordException(error);
76
- span.setStatus({
77
- code: SpanStatusCode.ERROR,
78
- message: error.message
79
- });
80
- }
75
+ if (error instanceof Error) await recordExceptionWithContext({
76
+ span,
77
+ error
78
+ });
81
79
  throw error;
82
80
  } finally {
83
81
  span.end();
@@ -0,0 +1,293 @@
1
+ import { MAX_CAUSE_DEPTH, buildCauseChain, getErrorCause, isErrorLike, stackWithCauses } from "./error_serializer.mjs";
2
+ import { SpanStatusCode } from "@opentelemetry/api";
3
+ import { createInterface } from "node:readline";
4
+ import { createReadStream } from "node:fs";
5
+ import { parseStack } from "error-stack-parser-es/lite";
6
+
7
+ //#region src/context_lines.ts
8
+ const DEFAULT_CONTEXT_LINES = 7;
9
+ const MAX_LINENO = 1e4;
10
+ const MAX_COLNO = 1e3;
11
+ const MAX_LINE_LENGTH = 200;
12
+ const FILE_CACHE_SIZE = 10;
13
+ const FAILED_CACHE_SIZE = 20;
14
+ /**
15
+ * Simple LRU cache implementation.
16
+ */
17
+ var LRUCache = class {
18
+ #maxSize;
19
+ #cache = /* @__PURE__ */ new Map();
20
+ constructor(maxSize) {
21
+ this.#maxSize = maxSize;
22
+ }
23
+ get(key) {
24
+ const value = this.#cache.get(key);
25
+ if (value === void 0) return void 0;
26
+ this.#cache.delete(key);
27
+ this.#cache.set(key, value);
28
+ return value;
29
+ }
30
+ set(key, value) {
31
+ this.#cache.delete(key);
32
+ if (this.#cache.size >= this.#maxSize) {
33
+ const oldestKey = this.#cache.keys().next().value;
34
+ if (oldestKey !== void 0) this.#cache.delete(oldestKey);
35
+ }
36
+ this.#cache.set(key, value);
37
+ }
38
+ has(key) {
39
+ return this.#cache.has(key);
40
+ }
41
+ clear() {
42
+ this.#cache.clear();
43
+ }
44
+ };
45
+ const fileCache = new LRUCache(FILE_CACHE_SIZE);
46
+ const failedCache = new LRUCache(FAILED_CACHE_SIZE);
47
+ /**
48
+ * Normalize file path by stripping file:// protocol.
49
+ */
50
+ function normalizeFilePath(path) {
51
+ if (path.startsWith("file://")) return path.slice(7);
52
+ return path;
53
+ }
54
+ /**
55
+ * Check if a file should be skipped for context extraction.
56
+ */
57
+ function shouldSkipFile(path) {
58
+ if (path.startsWith("node:")) return true;
59
+ if (path.startsWith("data:")) return true;
60
+ if (path.endsWith(".min.js")) return true;
61
+ if (path.endsWith(".min.cjs")) return true;
62
+ if (path.endsWith(".min.mjs")) return true;
63
+ if (path.includes("/node_modules/")) return true;
64
+ return false;
65
+ }
66
+ /**
67
+ * Check if a frame should be skipped based on line/column numbers.
68
+ */
69
+ function shouldSkipFrame(frame) {
70
+ if (frame.line === void 0) return true;
71
+ if (frame.line > MAX_LINENO) return true;
72
+ if (frame.col !== void 0 && frame.col > MAX_COLNO) return true;
73
+ return false;
74
+ }
75
+ /**
76
+ * Check if a frame is from the application code (not node_modules).
77
+ */
78
+ function isInApp(filename) {
79
+ return !filename.includes("/node_modules/");
80
+ }
81
+ /**
82
+ * Truncate long lines to prevent huge payloads.
83
+ */
84
+ function snipLine(line) {
85
+ if (line.length <= MAX_LINE_LENGTH) return line;
86
+ return line.slice(0, MAX_LINE_LENGTH) + "...";
87
+ }
88
+ /**
89
+ * Create context range around a line number.
90
+ */
91
+ function makeContextRange(lineno, contextLines) {
92
+ return [Math.max(1, lineno - contextLines), lineno + contextLines];
93
+ }
94
+ /**
95
+ * Merge overlapping line ranges for efficient file reading.
96
+ */
97
+ function mergeRanges(ranges) {
98
+ if (ranges.length === 0) return [];
99
+ const sorted = [...ranges].sort((a, b) => a[0] - b[0]);
100
+ const merged = [sorted[0]];
101
+ for (let i = 1; i < sorted.length; i++) {
102
+ const current = sorted[i];
103
+ const last = merged[merged.length - 1];
104
+ if (current[0] <= last[1] + 1) last[1] = Math.max(last[1], current[1]);
105
+ else merged.push(current);
106
+ }
107
+ return merged;
108
+ }
109
+ /**
110
+ * Read specific line ranges from a file.
111
+ * Follows Sentry's battle-tested patterns for stream handling.
112
+ */
113
+ async function readFileLines(filepath, ranges) {
114
+ return new Promise((resolve) => {
115
+ const output = {};
116
+ let resolved = false;
117
+ const stream = createReadStream(filepath);
118
+ const lineReader = createInterface({ input: stream });
119
+ function destroyStreamAndResolve() {
120
+ if (resolved) return;
121
+ resolved = true;
122
+ stream.destroy();
123
+ resolve(output);
124
+ }
125
+ if (ranges.length === 0) {
126
+ destroyStreamAndResolve();
127
+ return;
128
+ }
129
+ let lineNumber = 0;
130
+ let currentRangeIndex = 0;
131
+ let rangeStart = ranges[0][0];
132
+ let rangeEnd = ranges[0][1];
133
+ function onStreamError() {
134
+ failedCache.set(filepath, true);
135
+ lineReader.close();
136
+ lineReader.removeAllListeners();
137
+ destroyStreamAndResolve();
138
+ }
139
+ stream.on("error", onStreamError);
140
+ lineReader.on("error", onStreamError);
141
+ lineReader.on("close", destroyStreamAndResolve);
142
+ lineReader.on("line", (line) => {
143
+ try {
144
+ lineNumber++;
145
+ if (lineNumber < rangeStart) return;
146
+ output[lineNumber] = snipLine(line);
147
+ if (lineNumber >= rangeEnd) {
148
+ if (currentRangeIndex === ranges.length - 1) {
149
+ lineReader.close();
150
+ lineReader.removeAllListeners();
151
+ return;
152
+ }
153
+ currentRangeIndex++;
154
+ const nextRange = ranges[currentRangeIndex];
155
+ rangeStart = nextRange[0];
156
+ rangeEnd = nextRange[1];
157
+ }
158
+ } catch {}
159
+ });
160
+ });
161
+ }
162
+ /**
163
+ * Extract context lines for a stack trace.
164
+ */
165
+ async function extractContextLines(stack, contextLines = DEFAULT_CONTEXT_LINES) {
166
+ const parsedFrames = parseStack(stack);
167
+ const framesToEnrich = [];
168
+ for (const frame of parsedFrames) {
169
+ if (!frame.file || !frame.line) continue;
170
+ const filepath = normalizeFilePath(frame.file);
171
+ if (shouldSkipFile(filepath)) continue;
172
+ if (shouldSkipFrame(frame)) continue;
173
+ const range = makeContextRange(frame.line, contextLines);
174
+ framesToEnrich.push({
175
+ frame,
176
+ filepath,
177
+ range
178
+ });
179
+ }
180
+ if (framesToEnrich.length === 0) return [];
181
+ const fileToRanges = /* @__PURE__ */ new Map();
182
+ for (const { filepath, range } of framesToEnrich) {
183
+ const existing = fileToRanges.get(filepath) || [];
184
+ existing.push(range);
185
+ fileToRanges.set(filepath, existing);
186
+ }
187
+ const readPromises = [];
188
+ for (const [filepath, ranges] of fileToRanges) {
189
+ if (failedCache.has(filepath)) continue;
190
+ const cachedContent = fileCache.get(filepath);
191
+ const mergedRanges = mergeRanges(ranges);
192
+ if (cachedContent && mergedRanges.every(([start, end]) => {
193
+ for (let i = start; i <= end; i++) if (cachedContent[i] === void 0) return false;
194
+ return true;
195
+ })) continue;
196
+ readPromises.push(readFileLines(filepath, mergedRanges).then((lines) => {
197
+ const existing = fileCache.get(filepath) || {};
198
+ fileCache.set(filepath, {
199
+ ...existing,
200
+ ...lines
201
+ });
202
+ }));
203
+ }
204
+ await Promise.all(readPromises).catch(() => {});
205
+ const enrichedFrames = [];
206
+ for (const { frame, filepath, range } of framesToEnrich) {
207
+ const fileContent = fileCache.get(filepath);
208
+ if (!fileContent) continue;
209
+ const [rangeStart, rangeEnd] = range;
210
+ const preContext = [];
211
+ const postContext = [];
212
+ for (let i = rangeStart; i < frame.line; i++) {
213
+ const line = fileContent[i];
214
+ if (line !== void 0) preContext.push(line);
215
+ }
216
+ const contextLine = fileContent[frame.line];
217
+ if (contextLine === void 0) continue;
218
+ for (let i = frame.line + 1; i <= rangeEnd; i++) {
219
+ const line = fileContent[i];
220
+ if (line !== void 0) postContext.push(line);
221
+ }
222
+ enrichedFrames.push({
223
+ filename: filepath,
224
+ lineno: frame.line,
225
+ colno: frame.col,
226
+ function: frame.function,
227
+ in_app: isInApp(filepath),
228
+ pre_context: preContext,
229
+ context_line: contextLine,
230
+ post_context: postContext
231
+ });
232
+ }
233
+ return enrichedFrames;
234
+ }
235
+ /**
236
+ * Build a cause chain with context lines for each cause.
237
+ * This extracts context lines from each error's stack trace.
238
+ */
239
+ async function buildCauseChainWithContext(error, contextLines = DEFAULT_CONTEXT_LINES) {
240
+ return _buildCauseChainWithContext(error, /* @__PURE__ */ new Set(), 0, contextLines);
241
+ }
242
+ async function _buildCauseChainWithContext(err, seen, depth, contextLines) {
243
+ if (!isErrorLike(err)) return [];
244
+ if (depth >= MAX_CAUSE_DEPTH) return [{
245
+ type: "Error",
246
+ message: "(max cause depth reached)"
247
+ }];
248
+ if (seen.has(err)) return [{
249
+ type: "Error",
250
+ message: "(circular reference detected)"
251
+ }];
252
+ seen.add(err);
253
+ let frames;
254
+ if (err.stack) try {
255
+ const extracted = await extractContextLines(err.stack, contextLines);
256
+ if (extracted.length > 0) frames = extracted;
257
+ } catch {}
258
+ const current = {
259
+ type: err.name || "Error",
260
+ message: err.message || "",
261
+ stack: err.stack,
262
+ frames
263
+ };
264
+ const cause = getErrorCause(err);
265
+ if (cause) return [current, ...await _buildCauseChainWithContext(cause, seen, depth + 1, contextLines)];
266
+ return [current];
267
+ }
268
+ /**
269
+ * Record an exception on a span with context lines.
270
+ * This replaces `span.recordException()` with an enriched version.
271
+ */
272
+ async function recordExceptionWithContext(options) {
273
+ const { span, error, shouldReport = true, contextLines = DEFAULT_CONTEXT_LINES } = options;
274
+ span.setStatus({
275
+ code: SpanStatusCode.ERROR,
276
+ message: error.message
277
+ });
278
+ span.setAttribute("monocle.exception.should_report", shouldReport);
279
+ const causeChain = shouldReport ? await buildCauseChainWithContext(error, contextLines) : buildCauseChain(error);
280
+ const hasCauses = causeChain.length > 1;
281
+ const attributes = {
282
+ "exception.type": error.name,
283
+ "exception.message": error.message,
284
+ "exception.stacktrace": hasCauses ? stackWithCauses(error) : error.stack || ""
285
+ };
286
+ if (causeChain.length > 0) attributes["monocle.exception.cause_chain"] = JSON.stringify(causeChain);
287
+ const mainFrames = causeChain[0]?.frames;
288
+ if (mainFrames && mainFrames.length > 0) attributes["monocle.exception.frames"] = JSON.stringify(mainFrames);
289
+ span.addEvent("exception", attributes);
290
+ }
291
+
292
+ //#endregion
293
+ export { recordExceptionWithContext };
@@ -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 };
@@ -17,8 +17,11 @@ declare class Monocle {
17
17
  /**
18
18
  * Capture an exception and record it on the current active span.
19
19
  * If no span is active, the exception is silently ignored.
20
+ *
21
+ * This method is async to allow for source context extraction.
22
+ * You can choose to await it or fire-and-forget.
20
23
  */
21
- static captureException(error: unknown, context?: CaptureExceptionContext): void;
24
+ static captureException(error: unknown, context?: CaptureExceptionContext): Promise<void>;
22
25
  /**
23
26
  * Set user information on the current active span.
24
27
  */
@@ -1,4 +1,5 @@
1
- import { SpanStatusCode, trace } from "@opentelemetry/api";
1
+ import { recordExceptionWithContext } from "./context_lines.mjs";
2
+ import { trace } from "@opentelemetry/api";
2
3
  import { setUser } from "@adonisjs/otel/helpers";
3
4
 
4
5
  //#region src/monocle.ts
@@ -9,15 +10,16 @@ var Monocle = class {
9
10
  /**
10
11
  * Capture an exception and record it on the current active span.
11
12
  * If no span is active, the exception is silently ignored.
13
+ *
14
+ * This method is async to allow for source context extraction.
15
+ * You can choose to await it or fire-and-forget.
12
16
  */
13
- static captureException(error, context$1) {
17
+ static async captureException(error, context$1) {
14
18
  const span = trace.getActiveSpan();
15
19
  if (!span) return;
16
- const err = error instanceof Error ? error : new Error(String(error));
17
- span.recordException(err);
18
- span.setStatus({
19
- code: SpanStatusCode.ERROR,
20
- message: err.message
20
+ await recordExceptionWithContext({
21
+ span,
22
+ error: error instanceof Error ? error : new Error(String(error))
21
23
  });
22
24
  if (context$1?.user) {
23
25
  if (context$1.user.id) span.setAttribute("user.id", context$1.user.id);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monocle.sh/adonisjs-agent",
3
- "version": "1.0.0-beta.4",
3
+ "version": "1.0.0-beta.5",
4
4
  "description": "Monocle agent for AdonisJS - sends telemetry to Monocle cloud",
5
5
  "keywords": [
6
6
  "adonisjs",
@@ -52,7 +52,9 @@
52
52
  "build": "tsdown",
53
53
  "dev": "tsdown",
54
54
  "typecheck": "tsc --noEmit",
55
- "release": "release-it",
55
+ "test": "node --import tsx bin/test.ts",
56
+ "quick:test": "node --import tsx bin/test.ts",
57
+ "release": "release-it --preRelease=beta",
56
58
  "prepublishOnly": "pnpm run build"
57
59
  },
58
60
  "dependencies": {
@@ -65,12 +67,18 @@
65
67
  "@opentelemetry/sdk-metrics": "^2.2.0",
66
68
  "@opentelemetry/sdk-trace-base": "^2.2.0",
67
69
  "@opentelemetry/semantic-conventions": "^1.38.0",
70
+ "error-stack-parser-es": "^1.0.5",
68
71
  "import-in-the-middle": "^2.0.1"
69
72
  },
70
73
  "devDependencies": {
71
74
  "@adonisjs/core": "catalog:",
72
75
  "@adonisjs/tsconfig": "catalog:",
73
- "release-it": "^19.2.2"
76
+ "@japa/assert": "^4.2.0",
77
+ "@japa/file-system": "^2.3.2",
78
+ "@japa/runner": "^4.4.0",
79
+ "@japa/snapshot": "^2.0.10",
80
+ "release-it": "^19.2.2",
81
+ "tsx": "^4.19.4"
74
82
  },
75
83
  "peerDependencies": {
76
84
  "@adonisjs/core": "^6.2.0 || ^7.0.0"