flowtrace-omega 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/dashboard/index.html +2045 -0
- package/dashboard/public/docs.html +656 -0
- package/dashboard/public/favicon.png +0 -0
- package/dashboard/public/hero-bg.png +0 -0
- package/dashboard/public/how.html +337 -0
- package/dashboard/public/index.html +637 -0
- package/dashboard/server.js +152 -0
- package/index.js +115 -0
- package/package.json +56 -0
- package/src/context.js +74 -0
- package/src/exporter.js +112 -0
- package/src/integrations/graphql.js +101 -0
- package/src/integrations/http_client.js +107 -0
- package/src/integrations/index.js +26 -0
- package/src/integrations/mysql2.js +67 -0
- package/src/integrations/pg.js +79 -0
- package/src/otlp.js +120 -0
- package/src/wrapper.js +123 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dashboard/server.js — The Dashboard Server
|
|
3
|
+
*
|
|
4
|
+
* A lightweight Express server that runs in the background.
|
|
5
|
+
* Serves the visual dashboard at localhost:9999
|
|
6
|
+
* and exposes the /traces JSON API endpoint.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const express = require("express");
|
|
10
|
+
const path = require("path");
|
|
11
|
+
const { getAllTraces } = require("../src/exporter");
|
|
12
|
+
|
|
13
|
+
const app = express();
|
|
14
|
+
app.use(express.json());
|
|
15
|
+
|
|
16
|
+
// ── Static files (public folder with landing pages) ────
|
|
17
|
+
app.use(express.static(path.join(__dirname, "public")));
|
|
18
|
+
|
|
19
|
+
// ── Landing page routes ────────────────────────────────
|
|
20
|
+
app.get("/", (req, res) => {
|
|
21
|
+
res.sendFile(path.join(__dirname, "public", "index.html"));
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
app.get("/how", (req, res) => {
|
|
25
|
+
res.sendFile(path.join(__dirname, "public", "how.html"));
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
app.get("/docs", (req, res) => {
|
|
29
|
+
res.sendFile(path.join(__dirname, "public", "docs.html"));
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// ── Dashboard UI (actual tracing visualization) ────────
|
|
33
|
+
app.get("/app", (req, res) => {
|
|
34
|
+
res.sendFile(path.join(__dirname, "index.html"));
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// ── Traces API ─────────────────────────────────────────
|
|
38
|
+
// GET /traces — returns all recorded traces as JSON
|
|
39
|
+
app.get("/traces", (req, res) => {
|
|
40
|
+
res.json(getAllTraces());
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// DELETE /traces — clears the in-memory store (UI refresh)
|
|
44
|
+
app.delete("/traces", (req, res) => {
|
|
45
|
+
const store = getAllTraces();
|
|
46
|
+
store.splice(0, store.length); // mutate the shared array
|
|
47
|
+
res.json({ cleared: true });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// POST /java-traces — receive and stitch Java agent traces
|
|
51
|
+
app.post("/java-traces", (req, res) => {
|
|
52
|
+
const javaTraces = Array.isArray(req.body) ? req.body : [req.body];
|
|
53
|
+
const store = getAllTraces();
|
|
54
|
+
|
|
55
|
+
javaTraces.forEach(jt => {
|
|
56
|
+
const existing = store.find(t => t.traceId === jt.traceId);
|
|
57
|
+
if (existing) {
|
|
58
|
+
// Context Propagation: Stitch Java spans into the Node.js trace!
|
|
59
|
+
existing.spans = existing.spans.concat(jt.spans || []);
|
|
60
|
+
existing.totalDuration = Math.max(existing.totalDuration || 0, jt.totalDuration || 0);
|
|
61
|
+
} else {
|
|
62
|
+
store.push(jt);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
res.json({ success: true, stitched: javaTraces.length });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// POST /api/proxy — The API Tester (Postman clone) Backend Proxy
|
|
70
|
+
// Allows the frontend to bypass CORS and execute requests natively
|
|
71
|
+
app.post("/api/proxy", async (req, res) => {
|
|
72
|
+
const { url, method = "GET", headers = {}, body } = req.body;
|
|
73
|
+
|
|
74
|
+
if (!url) return res.status(400).json({ error: "URL is required" });
|
|
75
|
+
|
|
76
|
+
// Node 18+ has native fetch
|
|
77
|
+
try {
|
|
78
|
+
const startTime = Date.now();
|
|
79
|
+
|
|
80
|
+
const fetchOptions = {
|
|
81
|
+
method,
|
|
82
|
+
headers,
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
if (body && ["POST", "PUT", "PATCH"].includes(method.toUpperCase())) {
|
|
86
|
+
fetchOptions.body = typeof body === "string" ? body : JSON.stringify(body);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const response = await fetch(url, fetchOptions);
|
|
90
|
+
const latency = Date.now() - startTime;
|
|
91
|
+
|
|
92
|
+
// Capture Headers
|
|
93
|
+
const resHeaders = {};
|
|
94
|
+
response.headers.forEach((val, key) => {
|
|
95
|
+
resHeaders[key] = val;
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// Attempt parsing JSON
|
|
99
|
+
let data = await response.text();
|
|
100
|
+
try {
|
|
101
|
+
data = JSON.parse(data);
|
|
102
|
+
} catch(e) { }
|
|
103
|
+
|
|
104
|
+
res.json({
|
|
105
|
+
status: response.status,
|
|
106
|
+
statusText: response.statusText,
|
|
107
|
+
latency,
|
|
108
|
+
headers: resHeaders,
|
|
109
|
+
data,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
} catch (err) {
|
|
113
|
+
res.status(500).json({
|
|
114
|
+
error: "Proxy Request Failed",
|
|
115
|
+
message: err.message
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
let serverInstance = null;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Start the dashboard server.
|
|
124
|
+
* Safe to call multiple times — only starts once.
|
|
125
|
+
*
|
|
126
|
+
* @param {number} [port=9999]
|
|
127
|
+
*/
|
|
128
|
+
function startDashboard(port = 9999) {
|
|
129
|
+
if (serverInstance) return; // already running
|
|
130
|
+
|
|
131
|
+
serverInstance = app.listen(port, "0.0.0.0", () => {
|
|
132
|
+
console.log(`\n ⚡ FlowTrace dashboard running at http://localhost:${port}\n`);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
serverInstance.on("error", (err) => {
|
|
136
|
+
if (err.code === "EADDRINUSE") {
|
|
137
|
+
console.warn(`[FlowTrace] Port ${port} in use — dashboard not started. Try a different port.`);
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
return serverInstance;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ── Standalone Mode (Render/Direct Execution) ────────────
|
|
145
|
+
if (require.main === module) {
|
|
146
|
+
const port = process.env.PORT || 9999;
|
|
147
|
+
startDashboard(port);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ── Module Exports (Vercel/Library Execution) ────────────
|
|
151
|
+
module.exports = app;
|
|
152
|
+
module.exports.startDashboard = startDashboard;
|
package/index.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* index.js — The Initialization Module
|
|
3
|
+
*
|
|
4
|
+
* The single entry point for FlowTrace.
|
|
5
|
+
* The developer adds ONE line to their project:
|
|
6
|
+
*
|
|
7
|
+
* const FlowTrace = require('flowtrace');
|
|
8
|
+
* FlowTrace.start();
|
|
9
|
+
*
|
|
10
|
+
* That's it. Everything else happens automatically.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const http = require("http");
|
|
14
|
+
const crypto = require("crypto");
|
|
15
|
+
const { runWithTraceId, getStore } = require("./src/context");
|
|
16
|
+
const { finalizeTrace } = require("./src/exporter");
|
|
17
|
+
const { trace } = require("./src/wrapper");
|
|
18
|
+
const { startDashboard } = require("./dashboard/server");
|
|
19
|
+
const { initIntegrations } = require("./src/integrations/index");
|
|
20
|
+
|
|
21
|
+
let initialized = false;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Start FlowTrace.
|
|
25
|
+
*
|
|
26
|
+
* @param {Object} [options]
|
|
27
|
+
* @param {number} [options.port=9999] - Dashboard port
|
|
28
|
+
* @param {string} [options.outputPath] - Custom path for trace-output.json
|
|
29
|
+
* @param {boolean} [options.silent=false] - Suppress console output
|
|
30
|
+
*/
|
|
31
|
+
function start(options = {}) {
|
|
32
|
+
if (initialized) return;
|
|
33
|
+
initialized = true;
|
|
34
|
+
|
|
35
|
+
const { port = 9999, outputPath, silent = false, otlpEndpoint } = options;
|
|
36
|
+
|
|
37
|
+
// 1. Hook into require() to inject Database/GraphQL integrations dynamically
|
|
38
|
+
initIntegrations();
|
|
39
|
+
|
|
40
|
+
// 1b. Intercept outgoing HTTP requests
|
|
41
|
+
const { patchHttpClient } = require("./src/integrations/http_client");
|
|
42
|
+
patchHttpClient();
|
|
43
|
+
|
|
44
|
+
// 2. Start the visual dashboard server
|
|
45
|
+
startDashboard(port);
|
|
46
|
+
|
|
47
|
+
// 3. Monkey-patch Node's core http module so every incoming
|
|
48
|
+
// request automatically gets a traceId injected into its
|
|
49
|
+
// async context — zero config for the developer.
|
|
50
|
+
patchHttpModule({ outputPath, silent, otlpEndpoint });
|
|
51
|
+
|
|
52
|
+
if (!silent) {
|
|
53
|
+
console.log(" ✓ FlowTrace initialized — http module patched\n");
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Intercept http.createServer to wrap every incoming request
|
|
59
|
+
* in a new AsyncLocalStorage trace context.
|
|
60
|
+
*/
|
|
61
|
+
function patchHttpModule({ outputPath, silent, otlpEndpoint }) {
|
|
62
|
+
const originalCreateServer = http.createServer.bind(http);
|
|
63
|
+
|
|
64
|
+
http.createServer = function (options, requestListener) {
|
|
65
|
+
// Handle both http.createServer(listener) and http.createServer(opts, listener)
|
|
66
|
+
let listener = typeof options === "function" ? options : requestListener;
|
|
67
|
+
const serverOptions = typeof options === "object" ? options : {};
|
|
68
|
+
|
|
69
|
+
const wrappedListener = function (req, res) {
|
|
70
|
+
let traceId = crypto.randomUUID();
|
|
71
|
+
const tp = req.headers["traceparent"];
|
|
72
|
+
if (tp && typeof tp === "string") {
|
|
73
|
+
const parts = tp.split("-");
|
|
74
|
+
// W3C format: 00-{trace-id}-{span-id}-{flags}
|
|
75
|
+
if (parts.length === 4) traceId = parts[1];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Run the entire request handler inside a new trace context
|
|
79
|
+
runWithTraceId(traceId, () => {
|
|
80
|
+
const startTime = Date.now();
|
|
81
|
+
|
|
82
|
+
// Intercept res.end so we can finalize the trace when
|
|
83
|
+
// the response is sent — capturing the status code too
|
|
84
|
+
const originalEnd = res.end.bind(res);
|
|
85
|
+
res.end = function (...args) {
|
|
86
|
+
const store = getStore();
|
|
87
|
+
if (store) {
|
|
88
|
+
finalizeTrace({
|
|
89
|
+
traceId,
|
|
90
|
+
startTime,
|
|
91
|
+
method: req.method,
|
|
92
|
+
url: req.url,
|
|
93
|
+
statusCode: res.statusCode,
|
|
94
|
+
spans: store.spans,
|
|
95
|
+
outputPath,
|
|
96
|
+
otlpEndpoint,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
return originalEnd(...args);
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
if (listener) listener(req, res);
|
|
103
|
+
});
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
return Object.keys(serverOptions).length > 0
|
|
107
|
+
? originalCreateServer(serverOptions, wrappedListener)
|
|
108
|
+
: originalCreateServer(wrappedListener);
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
module.exports = {
|
|
113
|
+
start,
|
|
114
|
+
trace,
|
|
115
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "flowtrace-omega",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Zero-config dynamic tracing for Node.js. Add one line, get live sequence diagrams of every HTTP request.",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"flowtrace": "./bin/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"index.js",
|
|
11
|
+
"src/",
|
|
12
|
+
"dashboard/",
|
|
13
|
+
"adapters/",
|
|
14
|
+
"bin/",
|
|
15
|
+
"README.md"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"start": "node dashboard/server.js",
|
|
19
|
+
"test": "node test/smoke.js"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"tracing",
|
|
23
|
+
"observability",
|
|
24
|
+
"sequence-diagram",
|
|
25
|
+
"debugging",
|
|
26
|
+
"apm",
|
|
27
|
+
"async-local-storage",
|
|
28
|
+
"proxy",
|
|
29
|
+
"express",
|
|
30
|
+
"nestjs",
|
|
31
|
+
"distributed-tracing",
|
|
32
|
+
"developer-tools"
|
|
33
|
+
],
|
|
34
|
+
"author": "kavysharma",
|
|
35
|
+
"license": "MIT",
|
|
36
|
+
"repository": {
|
|
37
|
+
"type": "git",
|
|
38
|
+
"url": "https://github.com/skavy359/FlowTrace.git"
|
|
39
|
+
},
|
|
40
|
+
"bugs": {
|
|
41
|
+
"url": "https://github.com/skavy359/FlowTrace/issues"
|
|
42
|
+
},
|
|
43
|
+
"homepage": "https://github.com/skavy359/FlowTrace#readme",
|
|
44
|
+
"engines": {
|
|
45
|
+
"node": ">=18.0.0"
|
|
46
|
+
},
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"express": "^4.18.2"
|
|
49
|
+
},
|
|
50
|
+
"peerDependencies": {
|
|
51
|
+
"express": ">=4.0.0"
|
|
52
|
+
},
|
|
53
|
+
"peerDependenciesMeta": {
|
|
54
|
+
"express": { "optional": true }
|
|
55
|
+
}
|
|
56
|
+
}
|
package/src/context.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* context.js — The Context Manager
|
|
3
|
+
*
|
|
4
|
+
* Solves the async event loop problem using AsyncLocalStorage.
|
|
5
|
+
* Every async operation that flows from a single HTTP request
|
|
6
|
+
* will share the same traceId — automatically, with zero user config.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const { AsyncLocalStorage } = require("async_hooks");
|
|
10
|
+
|
|
11
|
+
// Singleton instance shared across the entire library
|
|
12
|
+
const asyncLocalStorage = new AsyncLocalStorage();
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Run a function within a new trace context.
|
|
16
|
+
* Everything called inside `fn` (sync or async) will share this traceId.
|
|
17
|
+
*
|
|
18
|
+
* @param {string} traceId - Unique ID for this request trace
|
|
19
|
+
* @param {Function} fn - The function to run inside the context
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
function runWithTraceId(traceId, fn) {
|
|
23
|
+
const store = {
|
|
24
|
+
traceId,
|
|
25
|
+
spans: [], // All spans collected for this trace
|
|
26
|
+
startTime: Date.now(),
|
|
27
|
+
};
|
|
28
|
+
asyncLocalStorage.run(store, fn);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Get the full store object for the current async context.
|
|
33
|
+
* Returns null if called outside a trace context.
|
|
34
|
+
*/
|
|
35
|
+
function getStore() {
|
|
36
|
+
return asyncLocalStorage.getStore() || null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Get just the traceId for the current async context.
|
|
41
|
+
*/
|
|
42
|
+
function getCurrentTraceId() {
|
|
43
|
+
const store = getStore();
|
|
44
|
+
return store ? store.traceId : null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Push a span into the current trace's span collection.
|
|
49
|
+
* Safe to call from anywhere — silently no-ops if no active context.
|
|
50
|
+
*
|
|
51
|
+
* @param {Object} span - The span object to record
|
|
52
|
+
*/
|
|
53
|
+
function pushSpan(span) {
|
|
54
|
+
const store = getStore();
|
|
55
|
+
if (store) {
|
|
56
|
+
store.spans.push(span);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Get all spans for the current trace context.
|
|
62
|
+
*/
|
|
63
|
+
function getCurrentSpans() {
|
|
64
|
+
const store = getStore();
|
|
65
|
+
return store ? store.spans : [];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
module.exports = {
|
|
69
|
+
runWithTraceId,
|
|
70
|
+
getStore,
|
|
71
|
+
getCurrentTraceId,
|
|
72
|
+
pushSpan,
|
|
73
|
+
getCurrentSpans,
|
|
74
|
+
};
|
package/src/exporter.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* exporter.js — The Data Exporter
|
|
3
|
+
*
|
|
4
|
+
* Handles persisting completed traces to disk as structured JSON.
|
|
5
|
+
* Also maintains an in-memory store of recent traces for the dashboard.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const fs = require("fs");
|
|
9
|
+
const path = require("path");
|
|
10
|
+
|
|
11
|
+
// In-memory ring buffer — keeps the last N traces for the live dashboard
|
|
12
|
+
const MAX_TRACES = 50;
|
|
13
|
+
const traceStore = [];
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A completed "Trace" is the top-level object that groups all spans
|
|
17
|
+
* from a single HTTP request lifecycle.
|
|
18
|
+
*
|
|
19
|
+
* @typedef {Object} Trace
|
|
20
|
+
* @property {string} traceId
|
|
21
|
+
* @property {number} startTime - Unix ms timestamp
|
|
22
|
+
* @property {number} totalDuration - ms from first to last span
|
|
23
|
+
* @property {string} method - HTTP method (GET, POST, …)
|
|
24
|
+
* @property {string} url - Request URL
|
|
25
|
+
* @property {number} statusCode - HTTP response status
|
|
26
|
+
* @property {Object[]} spans - All recorded spans
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const { exportToOtlp } = require("./otlp");
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Finalize a trace and push it to the store + disk.
|
|
33
|
+
*
|
|
34
|
+
* @param {Object} params
|
|
35
|
+
* @param {string} params.traceId
|
|
36
|
+
* @param {number} params.startTime
|
|
37
|
+
* @param {string} params.method
|
|
38
|
+
* @param {string} params.url
|
|
39
|
+
* @param {number} params.statusCode
|
|
40
|
+
* @param {Object[]} params.spans
|
|
41
|
+
* @param {string} [params.outputPath] - Where to write trace-output.json
|
|
42
|
+
* @param {string} [params.otlpEndpoint] - Optional OpenTelemetry endpoint (e.g. Jaeger)
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
function finalizeTrace({ traceId, startTime, method, url, statusCode, spans, outputPath, otlpEndpoint }) {
|
|
46
|
+
const endTime = Date.now();
|
|
47
|
+
|
|
48
|
+
const trace = {
|
|
49
|
+
traceId,
|
|
50
|
+
startTime,
|
|
51
|
+
endTime,
|
|
52
|
+
totalDuration: endTime - startTime,
|
|
53
|
+
method,
|
|
54
|
+
url,
|
|
55
|
+
statusCode,
|
|
56
|
+
spanCount: spans.length,
|
|
57
|
+
spans,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// Push to in-memory ring buffer
|
|
61
|
+
traceStore.unshift(trace);
|
|
62
|
+
if (traceStore.length > MAX_TRACES) {
|
|
63
|
+
traceStore.pop();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Write to disk (append mode — one JSON object per line = newline-delimited JSON)
|
|
67
|
+
const filePath = outputPath || path.join(process.cwd(), "trace-output.json");
|
|
68
|
+
persistTrace(trace, filePath);
|
|
69
|
+
|
|
70
|
+
// If the user configured an OpenTelemetry Collector, forward it!
|
|
71
|
+
if (otlpEndpoint) {
|
|
72
|
+
exportToOtlp(trace, otlpEndpoint);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return trace;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Write the entire current traceStore to the JSON file.
|
|
80
|
+
* We overwrite each time to keep the file clean and parseable.
|
|
81
|
+
*/
|
|
82
|
+
function persistTrace(trace, filePath) {
|
|
83
|
+
try {
|
|
84
|
+
let existing = [];
|
|
85
|
+
if (fs.existsSync(filePath)) {
|
|
86
|
+
const raw = fs.readFileSync(filePath, "utf-8").trim();
|
|
87
|
+
if (raw) existing = JSON.parse(raw);
|
|
88
|
+
}
|
|
89
|
+
existing.unshift(trace);
|
|
90
|
+
if (existing.length > MAX_TRACES) existing = existing.slice(0, MAX_TRACES);
|
|
91
|
+
fs.writeFileSync(filePath, JSON.stringify(existing, null, 2), "utf-8");
|
|
92
|
+
} catch (err) {
|
|
93
|
+
// Non-fatal — dashboard still works via in-memory store
|
|
94
|
+
console.warn(`[FlowTrace] Could not write trace-output.json: ${err.message}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Get all in-memory traces (for the dashboard API endpoint).
|
|
100
|
+
*/
|
|
101
|
+
function getAllTraces() {
|
|
102
|
+
return traceStore;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Get a single trace by ID.
|
|
107
|
+
*/
|
|
108
|
+
function getTrace(traceId) {
|
|
109
|
+
return traceStore.find((t) => t.traceId === traceId) || null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
module.exports = { finalizeTrace, getAllTraces, getTrace };
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
const { getCurrentTraceId, pushSpan } = require("../context");
|
|
2
|
+
|
|
3
|
+
let patched = false;
|
|
4
|
+
|
|
5
|
+
function patchGraphQL(graphql) {
|
|
6
|
+
if (!graphql || !graphql.execute) return graphql;
|
|
7
|
+
if (patched) return graphql;
|
|
8
|
+
patched = true;
|
|
9
|
+
|
|
10
|
+
const originalExecute = graphql.execute;
|
|
11
|
+
|
|
12
|
+
graphql.execute = function (...args) {
|
|
13
|
+
const traceId = getCurrentTraceId();
|
|
14
|
+
if (!traceId) return originalExecute.apply(this, args);
|
|
15
|
+
|
|
16
|
+
const startTime = performance.now();
|
|
17
|
+
let opName = "GraphQL Query";
|
|
18
|
+
let queryBody = "";
|
|
19
|
+
|
|
20
|
+
// GraphQL execute can receive an object or multiple arguments
|
|
21
|
+
let doc = null;
|
|
22
|
+
if (args.length === 1 && typeof args[0] === "object") {
|
|
23
|
+
doc = args[0].document;
|
|
24
|
+
if (args[0].operationName) opName = args[0].operationName;
|
|
25
|
+
} else if (args.length >= 2) {
|
|
26
|
+
doc = args[1];
|
|
27
|
+
if (args.length >= 6 && args[5]) opName = args[5];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (doc && doc.definitions) {
|
|
31
|
+
const def = doc.definitions.find(d => d.kind === 'OperationDefinition');
|
|
32
|
+
if (def && def.name && def.name.value) {
|
|
33
|
+
opName = def.name.value;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Attempt to extract the first level of fields to show what is being resolved
|
|
37
|
+
if (def && def.selectionSet && def.selectionSet.selections) {
|
|
38
|
+
const fields = def.selectionSet.selections
|
|
39
|
+
.filter(sel => sel.kind === 'Field')
|
|
40
|
+
.map(sel => sel.name.value);
|
|
41
|
+
if (fields.length > 0) {
|
|
42
|
+
queryBody = fields.join(", ");
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const spanId = `span_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
48
|
+
const span = {
|
|
49
|
+
spanId,
|
|
50
|
+
traceId,
|
|
51
|
+
label: "GraphQL",
|
|
52
|
+
callerLabel: "Node.js",
|
|
53
|
+
method: "RESOLVE",
|
|
54
|
+
args: [opName + (queryBody ? ` { ${queryBody} }` : "")],
|
|
55
|
+
startTime: Date.now(),
|
|
56
|
+
duration: null,
|
|
57
|
+
error: null,
|
|
58
|
+
type: "graphql",
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const result = originalExecute.apply(this, args);
|
|
63
|
+
|
|
64
|
+
if (result && typeof result.then === "function") {
|
|
65
|
+
return result
|
|
66
|
+
.then((res) => {
|
|
67
|
+
span.duration = +(performance.now() - startTime).toFixed(3);
|
|
68
|
+
if (res.errors && res.errors.length > 0) {
|
|
69
|
+
span.error = res.errors[0].message;
|
|
70
|
+
}
|
|
71
|
+
pushSpan(span);
|
|
72
|
+
return res;
|
|
73
|
+
})
|
|
74
|
+
.catch((err) => {
|
|
75
|
+
span.duration = +(performance.now() - startTime).toFixed(3);
|
|
76
|
+
span.error = err.message;
|
|
77
|
+
pushSpan(span);
|
|
78
|
+
throw err;
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Sync Execution
|
|
83
|
+
span.duration = +(performance.now() - startTime).toFixed(3);
|
|
84
|
+
if (result.errors && result.errors.length > 0) {
|
|
85
|
+
span.error = result.errors[0].message;
|
|
86
|
+
}
|
|
87
|
+
pushSpan(span);
|
|
88
|
+
return result;
|
|
89
|
+
|
|
90
|
+
} catch (err) {
|
|
91
|
+
span.duration = +(performance.now() - startTime).toFixed(3);
|
|
92
|
+
span.error = err.message;
|
|
93
|
+
pushSpan(span);
|
|
94
|
+
throw err;
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
return graphql;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
module.exports = { patchGraphQL };
|