@dcluttr/dclare-mcp 0.1.3 → 0.1.4
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/context/datasets.json +6226 -1362
- package/dist/config/env.js +3 -3
- package/dist/index.js +142 -68
- package/dist/services/auth-service.js +47 -14
- package/dist/services/cube-client.js +41 -7
- package/dist/services/langfuse-hook.js +72 -31
- package/dist/services/query-service.js +2 -1
- package/package.json +1 -1
package/dist/config/env.js
CHANGED
|
@@ -17,10 +17,10 @@ const envSchema = z.object({
|
|
|
17
17
|
CACHE_PREFIX: z.string().default("ttd:mcp:query"),
|
|
18
18
|
OTEL_ENABLED: z.coerce.boolean().default(true),
|
|
19
19
|
OTEL_CONSOLE_EXPORTER: z.coerce.boolean().default(false),
|
|
20
|
-
LANGFUSE_ENABLED: z.coerce.boolean().default(
|
|
20
|
+
LANGFUSE_ENABLED: z.coerce.boolean().default(true),
|
|
21
21
|
LANGFUSE_BASE_URL: z.string().url().default("https://cloud.langfuse.com"),
|
|
22
|
-
LANGFUSE_PUBLIC_KEY: z.string().
|
|
23
|
-
LANGFUSE_SECRET_KEY: z.string().
|
|
22
|
+
LANGFUSE_PUBLIC_KEY: z.string().default("pk-lf-aee41c32-cd4f-4cb2-9a57-66587bec56bf"),
|
|
23
|
+
LANGFUSE_SECRET_KEY: z.string().default("sk-lf-8d0542f8-9768-47c1-a684-6e97e67ef826"),
|
|
24
24
|
LANGFUSE_TIMEOUT_MS: z.coerce.number().int().positive().default(5000),
|
|
25
25
|
PLANNER_PROVIDER: z.enum(["heuristic", "openai"]).default("heuristic"),
|
|
26
26
|
OPENAI_API_KEY: z.string().optional(),
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import crypto from "node:crypto";
|
|
2
3
|
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
4
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
5
|
import { z } from "zod";
|
|
@@ -18,22 +19,84 @@ import { QueryService } from "./services/query-service.js";
|
|
|
18
19
|
import { ResultProfiler } from "./services/result-profiler.js";
|
|
19
20
|
import { plannerOutputSchema, queryFilterSchema, queryOrderSchema, queryTimeDimensionSchema } from "./types/planner.js";
|
|
20
21
|
const metadataStore = new MetadataStore();
|
|
21
|
-
const authService = new AuthService();
|
|
22
22
|
const observability = new ObservabilityService();
|
|
23
23
|
const langfuse = new LangfuseHook();
|
|
24
|
-
const
|
|
24
|
+
const authService = new AuthService(langfuse);
|
|
25
|
+
const queryService = new QueryService(metadataStore, new QueryGuardrails(), new CubeClient(langfuse), new ResultProfiler(), new QueryCache(), observability, langfuse);
|
|
25
26
|
const plannerService = new PlannerService(metadataStore);
|
|
26
27
|
const plannerExecutor = new PlannerExecutor(queryService);
|
|
27
28
|
const server = new McpServer({
|
|
28
29
|
name: env.MCP_SERVER_NAME,
|
|
29
30
|
version: env.MCP_SERVER_VERSION
|
|
30
31
|
});
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Tracking wrapper — creates a Langfuse trace for every tool call
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
function sanitizeArgs(toolName, args) {
|
|
36
|
+
if (toolName === "login_brand" && typeof args === "object" && args !== null) {
|
|
37
|
+
return { ...args, password: "***" };
|
|
38
|
+
}
|
|
39
|
+
return args;
|
|
40
|
+
}
|
|
41
|
+
async function tracked(toolName, args, handler) {
|
|
42
|
+
const traceId = crypto.randomUUID();
|
|
43
|
+
const startTime = Date.now();
|
|
44
|
+
langfuse.setTraceId(traceId);
|
|
45
|
+
try {
|
|
46
|
+
const result = await handler();
|
|
47
|
+
const isError = typeof result === "object" &&
|
|
48
|
+
result !== null &&
|
|
49
|
+
"isError" in result &&
|
|
50
|
+
result.isError === true;
|
|
51
|
+
await langfuse.createTrace({
|
|
52
|
+
id: traceId,
|
|
53
|
+
name: toolName,
|
|
54
|
+
userId: authService.getUserEmail(),
|
|
55
|
+
sessionId: authService.getSessionBrandId() ? `brand_${authService.getSessionBrandId()}` : undefined,
|
|
56
|
+
input: sanitizeArgs(toolName, args),
|
|
57
|
+
output: result,
|
|
58
|
+
metadata: {
|
|
59
|
+
duration_ms: Date.now() - startTime,
|
|
60
|
+
is_error: isError,
|
|
61
|
+
},
|
|
62
|
+
tags: isError ? ["mcp", toolName, "error"] : ["mcp", toolName],
|
|
63
|
+
level: isError ? "ERROR" : "DEFAULT",
|
|
64
|
+
});
|
|
65
|
+
return result;
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
await langfuse.createTrace({
|
|
69
|
+
id: traceId,
|
|
70
|
+
name: toolName,
|
|
71
|
+
userId: authService.getUserEmail(),
|
|
72
|
+
sessionId: authService.getSessionBrandId() ? `brand_${authService.getSessionBrandId()}` : undefined,
|
|
73
|
+
input: sanitizeArgs(toolName, args),
|
|
74
|
+
output: { error: error instanceof Error ? error.message : String(error) },
|
|
75
|
+
metadata: {
|
|
76
|
+
duration_ms: Date.now() - startTime,
|
|
77
|
+
is_error: true,
|
|
78
|
+
error_code: error instanceof AppError ? error.code : "UNKNOWN",
|
|
79
|
+
},
|
|
80
|
+
tags: ["mcp", toolName, "error"],
|
|
81
|
+
level: "ERROR",
|
|
82
|
+
});
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
langfuse.setTraceId(null);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// Tools
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
31
92
|
server.registerTool("list_datasets", {
|
|
32
93
|
title: "List datasets",
|
|
33
94
|
description: "Lists datasets, metrics, and dimensions available to the MCP client."
|
|
34
95
|
}, async () => {
|
|
35
|
-
|
|
36
|
-
|
|
96
|
+
return tracked("list_datasets", {}, async () => {
|
|
97
|
+
const datasets = metadataStore.listDatasets();
|
|
98
|
+
return textResult(`Found ${datasets.length} dataset(s).`, { datasets });
|
|
99
|
+
});
|
|
37
100
|
});
|
|
38
101
|
server.registerTool("search_datasets", {
|
|
39
102
|
title: "Search datasets",
|
|
@@ -42,11 +105,13 @@ server.registerTool("search_datasets", {
|
|
|
42
105
|
query: z.string().min(1).describe("Search keywords, e.g. 'blinkit city sku' or 'zepto ads keywords'")
|
|
43
106
|
}
|
|
44
107
|
}, async (args) => {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
108
|
+
return tracked("search_datasets", args, async () => {
|
|
109
|
+
const results = metadataStore.searchDatasets(args.query);
|
|
110
|
+
if (results.length === 0) {
|
|
111
|
+
return textResult("No datasets matched your search. Try broader keywords.");
|
|
112
|
+
}
|
|
113
|
+
return textResult(`Found ${JSON.stringify(results)} matching dataset(s).`, { datasets: results });
|
|
114
|
+
});
|
|
50
115
|
});
|
|
51
116
|
server.registerTool("get_dataset_context", {
|
|
52
117
|
title: "Get dataset context",
|
|
@@ -55,13 +120,15 @@ server.registerTool("get_dataset_context", {
|
|
|
55
120
|
dataset: z.string().min(1)
|
|
56
121
|
}
|
|
57
122
|
}, async (args) => {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
123
|
+
return tracked("get_dataset_context", args, async () => {
|
|
124
|
+
try {
|
|
125
|
+
const dataset = metadataStore.getDatasetWithJoins(args.dataset);
|
|
126
|
+
return textResult(`Loaded context for '${dataset.name}'. ${dataset.joinedDimensions.length} joined dimension(s) available.`, { dataset });
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
return toToolError(error);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
65
132
|
});
|
|
66
133
|
server.registerTool("run_semantic_query", {
|
|
67
134
|
title: "Run semantic query",
|
|
@@ -83,15 +150,17 @@ server.registerTool("run_semantic_query", {
|
|
|
83
150
|
})
|
|
84
151
|
}
|
|
85
152
|
}, async (args) => {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
153
|
+
return tracked("run_semantic_query", args, async () => {
|
|
154
|
+
try {
|
|
155
|
+
const tenant = authService.resolveTenantContext(args.tenantToken);
|
|
156
|
+
const token = authService.getToken(args.tenantToken);
|
|
157
|
+
const result = await queryService.runQuery(args.query, tenant, token);
|
|
158
|
+
return textResult(`Query executed for dataset '${result.dataset}'. Returned ${result.rowCount} row(s).\n\n${JSON.stringify(result.previewRows, null, 2)}`, result);
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
return toToolError(error);
|
|
162
|
+
}
|
|
163
|
+
});
|
|
95
164
|
});
|
|
96
165
|
server.registerTool("ask_data_question", {
|
|
97
166
|
title: "Ask data question",
|
|
@@ -113,47 +182,49 @@ server.registerTool("ask_data_question", {
|
|
|
113
182
|
execution: z.unknown().optional()
|
|
114
183
|
}
|
|
115
184
|
}, async (args) => {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
"planner.
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
datasetHint: args.datasetHint,
|
|
126
|
-
limitHint: args.limitHint
|
|
127
|
-
}));
|
|
128
|
-
await langfuse.capture({
|
|
129
|
-
name: "planner.generated",
|
|
130
|
-
input: {
|
|
185
|
+
return tracked("ask_data_question", args, async () => {
|
|
186
|
+
try {
|
|
187
|
+
return await observability.span("planner.ask_data_question", {
|
|
188
|
+
"planner.provider": env.PLANNER_PROVIDER,
|
|
189
|
+
"planner.execute": args.execute
|
|
190
|
+
}, async () => {
|
|
191
|
+
const tenant = authService.resolveTenantContext(args.tenantToken);
|
|
192
|
+
const token = authService.getToken(args.tenantToken);
|
|
193
|
+
const plan = plannerOutputSchema.parse(await plannerService.plan({
|
|
131
194
|
question: args.question,
|
|
132
|
-
datasetHint: args.datasetHint
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
195
|
+
datasetHint: args.datasetHint,
|
|
196
|
+
limitHint: args.limitHint
|
|
197
|
+
}));
|
|
198
|
+
await langfuse.capture({
|
|
199
|
+
name: "planner.generated",
|
|
200
|
+
input: {
|
|
201
|
+
question: args.question,
|
|
202
|
+
datasetHint: args.datasetHint
|
|
203
|
+
},
|
|
204
|
+
output: plan,
|
|
205
|
+
metadata: {
|
|
206
|
+
provider: env.PLANNER_PROVIDER,
|
|
207
|
+
tenant: tenant.brandId
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
if (!args.execute) {
|
|
211
|
+
return textResult("Generated semantic plan only.", {
|
|
212
|
+
plan,
|
|
213
|
+
executed: false
|
|
214
|
+
});
|
|
138
215
|
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
return textResult("Generated semantic plan only.", {
|
|
216
|
+
const execution = await plannerExecutor.executePlan(plan, tenant, token);
|
|
217
|
+
return textResult(`Planned and executed query for dataset '${execution.dataset}'. Returned ${execution.rowCount} row(s).\n\n${JSON.stringify(execution.previewRows, null, 2)}`, {
|
|
142
218
|
plan,
|
|
143
|
-
|
|
219
|
+
execution,
|
|
220
|
+
executed: true
|
|
144
221
|
});
|
|
145
|
-
}
|
|
146
|
-
const execution = await plannerExecutor.executePlan(plan, tenant, token);
|
|
147
|
-
return textResult(`Planned and executed query for dataset '${execution.dataset}'. Returned ${execution.rowCount} row(s).\n\n${JSON.stringify(execution.previewRows, null, 2)}`, {
|
|
148
|
-
plan,
|
|
149
|
-
execution,
|
|
150
|
-
executed: true
|
|
151
222
|
});
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
}
|
|
223
|
+
}
|
|
224
|
+
catch (error) {
|
|
225
|
+
return toToolError(error);
|
|
226
|
+
}
|
|
227
|
+
});
|
|
157
228
|
});
|
|
158
229
|
server.registerTool("login_brand", {
|
|
159
230
|
title: "Login brand",
|
|
@@ -163,13 +234,15 @@ server.registerTool("login_brand", {
|
|
|
163
234
|
password: z.string().min(1)
|
|
164
235
|
}
|
|
165
236
|
}, async (args) => {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
237
|
+
return tracked("login_brand", args, async () => {
|
|
238
|
+
try {
|
|
239
|
+
const { brandId } = await authService.login(args.email, args.password);
|
|
240
|
+
return textResult(`Logged in successfully. Active brand ID: ${brandId}.`, { brandId });
|
|
241
|
+
}
|
|
242
|
+
catch (error) {
|
|
243
|
+
return toToolError(error);
|
|
244
|
+
}
|
|
245
|
+
});
|
|
173
246
|
});
|
|
174
247
|
server.resource("catalog", new ResourceTemplate("catalog://datasets", { list: undefined }), {
|
|
175
248
|
title: "Dataset catalog",
|
|
@@ -185,6 +258,7 @@ server.resource("catalog", new ResourceTemplate("catalog://datasets", { list: un
|
|
|
185
258
|
]
|
|
186
259
|
}));
|
|
187
260
|
async function main() {
|
|
261
|
+
process.stderr.write(`[dclare-mcp] langfuse enabled=${env.LANGFUSE_ENABLED} pk=${env.LANGFUSE_PUBLIC_KEY ? "set" : "missing"} sk=${env.LANGFUSE_SECRET_KEY ? "set" : "missing"}\n`);
|
|
188
262
|
const transport = new StdioServerTransport();
|
|
189
263
|
await server.connect(transport);
|
|
190
264
|
}
|
|
@@ -1,42 +1,69 @@
|
|
|
1
|
+
import axios from "axios";
|
|
1
2
|
import { env } from "../config/env.js";
|
|
2
3
|
import { AppError } from "../utils/errors.js";
|
|
3
4
|
export class AuthService {
|
|
5
|
+
langfuse;
|
|
4
6
|
session = null;
|
|
7
|
+
constructor(langfuse) {
|
|
8
|
+
this.langfuse = langfuse;
|
|
9
|
+
}
|
|
5
10
|
async login(email, password) {
|
|
6
|
-
const
|
|
7
|
-
const timeout = setTimeout(() => controller.abort(), env.AUTH_LOGIN_TIMEOUT_MS);
|
|
11
|
+
const startTime = Date.now();
|
|
8
12
|
try {
|
|
9
|
-
const response = await
|
|
10
|
-
method: "POST",
|
|
13
|
+
const response = await axios.post(env.AUTH_LOGIN_URL, { email, password }, {
|
|
11
14
|
headers: {
|
|
12
15
|
"Content-Type": "application/json",
|
|
13
16
|
Accept: "application/json"
|
|
14
17
|
},
|
|
15
|
-
|
|
16
|
-
signal: controller.signal
|
|
18
|
+
timeout: env.AUTH_LOGIN_TIMEOUT_MS
|
|
17
19
|
});
|
|
18
|
-
|
|
19
|
-
throw new AppError("AUTH_FAILED", "Login failed. Please check your email and password.", 401);
|
|
20
|
-
}
|
|
21
|
-
const data = (await response.json());
|
|
20
|
+
const data = response.data;
|
|
22
21
|
const nested = (data.data ?? {});
|
|
23
22
|
const token = String(data.token ?? data.access_token ?? data.accessToken ?? nested.accessToken ?? nested.access_token ?? nested.token ?? "");
|
|
23
|
+
await this.langfuse.capture({
|
|
24
|
+
name: "api.auth.login",
|
|
25
|
+
input: {
|
|
26
|
+
url: env.AUTH_LOGIN_URL,
|
|
27
|
+
email,
|
|
28
|
+
},
|
|
29
|
+
output: {
|
|
30
|
+
status: response.status,
|
|
31
|
+
hasToken: !!token,
|
|
32
|
+
duration_ms: Date.now() - startTime,
|
|
33
|
+
},
|
|
34
|
+
});
|
|
24
35
|
if (!token) {
|
|
25
36
|
throw new AppError("AUTH_FAILED", "Login succeeded but session could not be established. Please try again.", 401);
|
|
26
37
|
}
|
|
27
38
|
const payload = this.decodeJwtPayload(token);
|
|
28
39
|
const { brandId, role } = this.extractTenant(payload);
|
|
29
|
-
this.session = { token, brandId, role };
|
|
40
|
+
this.session = { token, brandId, role, email };
|
|
30
41
|
return { brandId };
|
|
31
42
|
}
|
|
32
43
|
catch (error) {
|
|
33
44
|
if (error instanceof AppError)
|
|
34
45
|
throw error;
|
|
46
|
+
const status = axios.isAxiosError(error) ? error.response?.status : undefined;
|
|
47
|
+
const responseData = axios.isAxiosError(error) ? error.response?.data : undefined;
|
|
48
|
+
await this.langfuse.capture({
|
|
49
|
+
name: "api.auth.login",
|
|
50
|
+
input: {
|
|
51
|
+
url: env.AUTH_LOGIN_URL,
|
|
52
|
+
email,
|
|
53
|
+
},
|
|
54
|
+
output: {
|
|
55
|
+
status,
|
|
56
|
+
error: error instanceof Error ? error.message : String(error),
|
|
57
|
+
responseData,
|
|
58
|
+
duration_ms: Date.now() - startTime,
|
|
59
|
+
},
|
|
60
|
+
level: "ERROR",
|
|
61
|
+
});
|
|
62
|
+
if (status === 401 || status === 403) {
|
|
63
|
+
throw new AppError("AUTH_FAILED", "Login failed. Please check your email and password.", 401);
|
|
64
|
+
}
|
|
35
65
|
throw new AppError("AUTH_FAILED", "Failed to connect to the authentication service.", 502);
|
|
36
66
|
}
|
|
37
|
-
finally {
|
|
38
|
-
clearTimeout(timeout);
|
|
39
|
-
}
|
|
40
67
|
}
|
|
41
68
|
getToken(explicitToken) {
|
|
42
69
|
if (explicitToken)
|
|
@@ -68,6 +95,12 @@ export class AuthService {
|
|
|
68
95
|
const role = roles.includes("write_user") ? "write_user" : roles[0] ?? "brand_user";
|
|
69
96
|
return { brandId, role };
|
|
70
97
|
}
|
|
98
|
+
getUserEmail() {
|
|
99
|
+
return this.session?.email ?? null;
|
|
100
|
+
}
|
|
101
|
+
getSessionBrandId() {
|
|
102
|
+
return this.session?.brandId ?? null;
|
|
103
|
+
}
|
|
71
104
|
decodeJwtPayload(token) {
|
|
72
105
|
try {
|
|
73
106
|
const parts = token.split(".");
|
|
@@ -2,9 +2,15 @@ import axios from "axios";
|
|
|
2
2
|
import { env } from "../config/env.js";
|
|
3
3
|
import { AppError } from "../utils/errors.js";
|
|
4
4
|
export class CubeClient {
|
|
5
|
+
langfuse;
|
|
6
|
+
constructor(langfuse) {
|
|
7
|
+
this.langfuse = langfuse;
|
|
8
|
+
}
|
|
5
9
|
async load(query, brandId, authToken) {
|
|
10
|
+
const url = `${env.CUBE_API_URL}/v1/load`;
|
|
11
|
+
const startTime = Date.now();
|
|
6
12
|
try {
|
|
7
|
-
const response = await axios.post(
|
|
13
|
+
const response = await axios.post(url, { query }, {
|
|
8
14
|
headers: {
|
|
9
15
|
"Content-Type": "application/json",
|
|
10
16
|
Authorization: authToken,
|
|
@@ -12,17 +18,45 @@ export class CubeClient {
|
|
|
12
18
|
},
|
|
13
19
|
timeout: env.CUBE_QUERY_TIMEOUT_MS
|
|
14
20
|
});
|
|
21
|
+
const rowCount = response.data?.data?.length ?? 0;
|
|
22
|
+
await this.langfuse.capture({
|
|
23
|
+
name: "api.cube.load",
|
|
24
|
+
input: {
|
|
25
|
+
url,
|
|
26
|
+
brandId,
|
|
27
|
+
cubeQuery: query,
|
|
28
|
+
},
|
|
29
|
+
output: {
|
|
30
|
+
status: response.status,
|
|
31
|
+
rowCount,
|
|
32
|
+
lastRefreshTime: response.data?.lastRefreshTime ?? null,
|
|
33
|
+
duration_ms: Date.now() - startTime,
|
|
34
|
+
},
|
|
35
|
+
});
|
|
15
36
|
return response.data;
|
|
16
37
|
}
|
|
17
38
|
catch (error) {
|
|
18
39
|
if (error instanceof AppError)
|
|
19
40
|
throw error;
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
41
|
+
const status = axios.isAxiosError(error) ? error.response?.status : undefined;
|
|
42
|
+
const responseData = axios.isAxiosError(error) ? error.response?.data : undefined;
|
|
43
|
+
await this.langfuse.capture({
|
|
44
|
+
name: "api.cube.load",
|
|
45
|
+
input: {
|
|
46
|
+
url,
|
|
47
|
+
brandId,
|
|
48
|
+
cubeQuery: query,
|
|
49
|
+
},
|
|
50
|
+
output: {
|
|
51
|
+
status,
|
|
52
|
+
error: error instanceof Error ? error.message : String(error),
|
|
53
|
+
responseData,
|
|
54
|
+
duration_ms: Date.now() - startTime,
|
|
55
|
+
},
|
|
56
|
+
level: "ERROR",
|
|
57
|
+
});
|
|
58
|
+
if (status === 401 || status === 403) {
|
|
59
|
+
throw new AppError("QUERY_ERROR", "Authentication expired. Please login again using login_brand.", 401);
|
|
26
60
|
}
|
|
27
61
|
throw new AppError("QUERY_ERROR", "Failed to execute query. Please try again.", 502);
|
|
28
62
|
}
|
|
@@ -1,46 +1,87 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import axios from "axios";
|
|
2
4
|
import { env } from "../config/env.js";
|
|
3
5
|
export class LangfuseHook {
|
|
6
|
+
currentTraceId = null;
|
|
7
|
+
deviceInfo;
|
|
8
|
+
constructor() {
|
|
9
|
+
this.deviceInfo = {
|
|
10
|
+
platform: os.platform(),
|
|
11
|
+
arch: os.arch(),
|
|
12
|
+
nodeVersion: process.version,
|
|
13
|
+
hostname: os.hostname(),
|
|
14
|
+
serverVersion: env.MCP_SERVER_VERSION,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
get enabled() {
|
|
18
|
+
return env.LANGFUSE_ENABLED && !!env.LANGFUSE_PUBLIC_KEY && !!env.LANGFUSE_SECRET_KEY;
|
|
19
|
+
}
|
|
20
|
+
setTraceId(id) {
|
|
21
|
+
this.currentTraceId = id;
|
|
22
|
+
}
|
|
23
|
+
getDeviceInfo() {
|
|
24
|
+
return { ...this.deviceInfo };
|
|
25
|
+
}
|
|
26
|
+
async createTrace(trace) {
|
|
27
|
+
if (!this.enabled)
|
|
28
|
+
return;
|
|
29
|
+
await this.send([
|
|
30
|
+
{
|
|
31
|
+
id: crypto.randomUUID(),
|
|
32
|
+
type: "trace-create",
|
|
33
|
+
timestamp: new Date().toISOString(),
|
|
34
|
+
body: {
|
|
35
|
+
id: trace.id,
|
|
36
|
+
name: trace.name,
|
|
37
|
+
userId: trace.userId ?? undefined,
|
|
38
|
+
sessionId: trace.sessionId ?? undefined,
|
|
39
|
+
input: trace.input,
|
|
40
|
+
output: trace.output,
|
|
41
|
+
metadata: { ...this.deviceInfo, ...(trace.metadata ?? {}) },
|
|
42
|
+
tags: trace.tags,
|
|
43
|
+
level: trace.level,
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
]);
|
|
47
|
+
}
|
|
4
48
|
async capture(event) {
|
|
5
|
-
if (!
|
|
49
|
+
if (!this.enabled)
|
|
6
50
|
return;
|
|
7
|
-
|
|
51
|
+
await this.send([
|
|
52
|
+
{
|
|
53
|
+
id: crypto.randomUUID(),
|
|
54
|
+
type: "event-create",
|
|
55
|
+
timestamp: new Date().toISOString(),
|
|
56
|
+
body: {
|
|
57
|
+
traceId: this.currentTraceId ?? undefined,
|
|
58
|
+
name: event.name,
|
|
59
|
+
input: event.input,
|
|
60
|
+
output: event.output,
|
|
61
|
+
metadata: event.metadata,
|
|
62
|
+
level: event.level ?? "DEFAULT",
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
]);
|
|
66
|
+
}
|
|
67
|
+
async send(batch) {
|
|
8
68
|
const auth = Buffer.from(`${env.LANGFUSE_PUBLIC_KEY}:${env.LANGFUSE_SECRET_KEY}`).toString("base64");
|
|
9
|
-
const
|
|
10
|
-
const body = {
|
|
11
|
-
batch: [
|
|
12
|
-
{
|
|
13
|
-
id: crypto.randomUUID(),
|
|
14
|
-
type: "event-create",
|
|
15
|
-
timestamp,
|
|
16
|
-
body: {
|
|
17
|
-
name: event.name,
|
|
18
|
-
input: event.input,
|
|
19
|
-
output: event.output,
|
|
20
|
-
metadata: event.metadata,
|
|
21
|
-
level: event.level ?? "DEFAULT"
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
]
|
|
25
|
-
};
|
|
26
|
-
const controller = new AbortController();
|
|
27
|
-
const timeout = setTimeout(() => controller.abort(), env.LANGFUSE_TIMEOUT_MS);
|
|
69
|
+
const url = `${env.LANGFUSE_BASE_URL}/api/public/ingestion`;
|
|
28
70
|
try {
|
|
29
|
-
await
|
|
30
|
-
method: "POST",
|
|
71
|
+
const response = await axios.post(url, { batch }, {
|
|
31
72
|
headers: {
|
|
32
73
|
Authorization: `Basic ${auth}`,
|
|
33
|
-
"Content-Type": "application/json"
|
|
74
|
+
"Content-Type": "application/json",
|
|
34
75
|
},
|
|
35
|
-
|
|
36
|
-
signal: controller.signal
|
|
76
|
+
timeout: env.LANGFUSE_TIMEOUT_MS,
|
|
37
77
|
});
|
|
78
|
+
process.stderr.write(`[langfuse] sent ${batch.length} item(s) → ${response.status}\n`);
|
|
38
79
|
}
|
|
39
|
-
catch {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
80
|
+
catch (error) {
|
|
81
|
+
const msg = axios.isAxiosError(error)
|
|
82
|
+
? `${error.response?.status ?? "network"}: ${JSON.stringify(error.response?.data ?? error.message)}`
|
|
83
|
+
: String(error);
|
|
84
|
+
process.stderr.write(`[langfuse] error: ${msg}\n`);
|
|
44
85
|
}
|
|
45
86
|
}
|
|
46
87
|
}
|