ralph-hero-mcp-server 2.5.139 → 2.5.140
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/dist/lib/error-signature.js +194 -0
- package/dist/lib/langfuse-client.js +95 -0
- package/dist/tools/debug-tools.js +72 -91
- package/package.json +1 -1
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error-signature normalization and grouping for Langfuse OTel spans.
|
|
3
|
+
*
|
|
4
|
+
* Used by `ralph_hero__collate_debug` to collapse noisy, near-identical
|
|
5
|
+
* error spans into a small set of "signatures." Each signature is hashed to
|
|
6
|
+
* an 8-char ID that survives across runs, so Phase 3b's GitHub dedup can
|
|
7
|
+
* match an incoming group to an existing issue body by the hash marker.
|
|
8
|
+
*
|
|
9
|
+
* The normalization rules deliberately strip *dynamic* details (issue
|
|
10
|
+
* numbers, timestamps, UUIDs, hashes, quoted paths/names) while preserving
|
|
11
|
+
* the *structural* shape of the message. Two errors that differ only in
|
|
12
|
+
* which issue number triggered them collapse to the same signature.
|
|
13
|
+
*/
|
|
14
|
+
import { createHash } from "node:crypto";
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Normalization
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
const ISO_TIMESTAMP_RE = /\b\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?\b/g;
|
|
19
|
+
const UUID_RE = /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi;
|
|
20
|
+
const HEX_HASH_RE = /\b[0-9a-f]{8,}\b/gi;
|
|
21
|
+
const ISSUE_NUMBER_RE = /#\d+/g;
|
|
22
|
+
// Match runs of digits anywhere — without word boundaries so embedded
|
|
23
|
+
// numbers like "60s" or "v2" collapse too. ISSUE_NUMBER_RE runs first so
|
|
24
|
+
// "#42" becomes "#N" before this fires.
|
|
25
|
+
const BARE_NUMBER_RE = /\d+/g;
|
|
26
|
+
// Match double-quoted or single-quoted strings (non-greedy).
|
|
27
|
+
const QUOTED_STRING_RE = /"[^"\n]*"|'[^'\n]*'/g;
|
|
28
|
+
/**
|
|
29
|
+
* Normalize an error message into a comparable signature fragment.
|
|
30
|
+
*
|
|
31
|
+
* Order of replacements matters:
|
|
32
|
+
* 1. Quoted strings first (so a quoted ISO timestamp becomes `<STR>` not
|
|
33
|
+
* `<TS>`).
|
|
34
|
+
* 2. ISO timestamps before UUIDs (timestamps can contain colons / dashes).
|
|
35
|
+
* 3. UUIDs before generic hex hashes (UUID format is stricter).
|
|
36
|
+
* 4. Issue numbers (`#NNN`) before bare numbers.
|
|
37
|
+
* 5. Bare numbers last.
|
|
38
|
+
* 6. Whitespace collapsed and result truncated to 200 chars.
|
|
39
|
+
*/
|
|
40
|
+
export function normalizeErrorMessage(msg) {
|
|
41
|
+
if (!msg)
|
|
42
|
+
return "";
|
|
43
|
+
let out = msg;
|
|
44
|
+
out = out.replace(QUOTED_STRING_RE, "<STR>");
|
|
45
|
+
out = out.replace(ISO_TIMESTAMP_RE, "<TS>");
|
|
46
|
+
out = out.replace(UUID_RE, "<ID>");
|
|
47
|
+
out = out.replace(HEX_HASH_RE, "<HASH>");
|
|
48
|
+
out = out.replace(ISSUE_NUMBER_RE, "#N");
|
|
49
|
+
out = out.replace(BARE_NUMBER_RE, "<N>");
|
|
50
|
+
out = out.replace(/\s+/g, " ").trim();
|
|
51
|
+
return out.slice(0, 200);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Build the signature key (pre-hash). Format:
|
|
55
|
+
* `${spanName}:${errorType}:${normalizedMessage}`
|
|
56
|
+
*/
|
|
57
|
+
export function buildSignatureKey(spanName, errorType, normalizedMsg) {
|
|
58
|
+
return `${spanName}:${errorType}:${normalizedMsg}`;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* SHA256 hash truncated to 8 hex chars. Stable across runs, suitable for
|
|
62
|
+
* dedup body markers like `**Hash**: \`a1b2c3d4\``.
|
|
63
|
+
*/
|
|
64
|
+
export function hashSignature(key) {
|
|
65
|
+
return createHash("sha256").update(key).digest("hex").slice(0, 8);
|
|
66
|
+
}
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// Span helpers
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
/**
|
|
71
|
+
* Extract the `ralph_hero.error_type` attribute from a span's metadata, with
|
|
72
|
+
* fallback to a hoisted `errorType` field. Returns `"unknown"` if neither is
|
|
73
|
+
* present.
|
|
74
|
+
*/
|
|
75
|
+
export function getErrorType(span) {
|
|
76
|
+
if (span.errorType)
|
|
77
|
+
return span.errorType;
|
|
78
|
+
const meta = span.metadata ?? {};
|
|
79
|
+
const fromMeta = meta["ralph_hero.error_type"] ??
|
|
80
|
+
meta.error_type ??
|
|
81
|
+
meta.errorType;
|
|
82
|
+
if (typeof fromMeta === "string" && fromMeta.length > 0)
|
|
83
|
+
return fromMeta;
|
|
84
|
+
return "unknown";
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Extract the error message from a span. Prefers `message` (Langfuse
|
|
88
|
+
* `statusMessage`), then `metadata.exception.message`, then `metadata.error`.
|
|
89
|
+
*/
|
|
90
|
+
export function getErrorMessage(span) {
|
|
91
|
+
if (span.message)
|
|
92
|
+
return span.message;
|
|
93
|
+
const meta = span.metadata ?? {};
|
|
94
|
+
const exception = meta.exception;
|
|
95
|
+
if (exception &&
|
|
96
|
+
typeof exception === "object" &&
|
|
97
|
+
"message" in exception &&
|
|
98
|
+
typeof exception.message === "string") {
|
|
99
|
+
return exception.message;
|
|
100
|
+
}
|
|
101
|
+
if (typeof meta.error === "string")
|
|
102
|
+
return meta.error;
|
|
103
|
+
if (typeof meta.message === "string")
|
|
104
|
+
return meta.message;
|
|
105
|
+
return "";
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Convert a `LangfuseObservation` to a `SignatureSpan`. Hoists the
|
|
109
|
+
* `ralph_hero.error_type` attribute up to the top level.
|
|
110
|
+
*/
|
|
111
|
+
export function observationToSpan(obs) {
|
|
112
|
+
const meta = obs.metadata ?? {};
|
|
113
|
+
const errorType = typeof meta["ralph_hero.error_type"] === "string"
|
|
114
|
+
? meta["ralph_hero.error_type"]
|
|
115
|
+
: undefined;
|
|
116
|
+
return {
|
|
117
|
+
name: obs.name,
|
|
118
|
+
traceId: obs.traceId,
|
|
119
|
+
startTime: obs.startTime,
|
|
120
|
+
endTime: obs.endTime,
|
|
121
|
+
metadata: meta,
|
|
122
|
+
errorType,
|
|
123
|
+
message: obs.statusMessage,
|
|
124
|
+
level: obs.level,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
// Grouping
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
function buildTraceUrl(langfuseHost, projectId, traceId) {
|
|
131
|
+
const host = (langfuseHost ?? "http://localhost:3100").replace(/\/+$/, "");
|
|
132
|
+
const project = projectId ?? "<defaultProjectId>";
|
|
133
|
+
return `${host}/project/${project}/traces/${traceId}`;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Group spans by signature. Returns groups sorted by `count` descending.
|
|
137
|
+
*
|
|
138
|
+
* Spans below `minOccurrences` (default 3) are filtered out. Each group's
|
|
139
|
+
* `sampleSpans` contains up to 3 representative spans, most-recent first.
|
|
140
|
+
*/
|
|
141
|
+
export function groupSpansBySignature(spans, opts = {}) {
|
|
142
|
+
const minOccurrences = opts.minOccurrences ?? 3;
|
|
143
|
+
const buckets = new Map();
|
|
144
|
+
for (const span of spans) {
|
|
145
|
+
const errorType = getErrorType(span);
|
|
146
|
+
const normalized = normalizeErrorMessage(getErrorMessage(span));
|
|
147
|
+
const signature = buildSignatureKey(span.name, errorType, normalized);
|
|
148
|
+
const hash = hashSignature(signature);
|
|
149
|
+
const existing = buckets.get(hash);
|
|
150
|
+
if (existing) {
|
|
151
|
+
existing.count += 1;
|
|
152
|
+
if (span.startTime > existing.lastSeen) {
|
|
153
|
+
existing.lastSeen = span.startTime;
|
|
154
|
+
existing.latestTraceId = span.traceId;
|
|
155
|
+
}
|
|
156
|
+
if (span.startTime < existing.firstSeen) {
|
|
157
|
+
existing.firstSeen = span.startTime;
|
|
158
|
+
}
|
|
159
|
+
existing.spans.push(span);
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
buckets.set(hash, {
|
|
163
|
+
signature,
|
|
164
|
+
hash,
|
|
165
|
+
count: 1,
|
|
166
|
+
firstSeen: span.startTime,
|
|
167
|
+
lastSeen: span.startTime,
|
|
168
|
+
latestTraceId: span.traceId,
|
|
169
|
+
spans: [span],
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
const groups = [];
|
|
174
|
+
for (const bucket of buckets.values()) {
|
|
175
|
+
if (bucket.count < minOccurrences)
|
|
176
|
+
continue;
|
|
177
|
+
// Sort sample spans by startTime desc, keep up to 3.
|
|
178
|
+
const sampleSpans = [...bucket.spans]
|
|
179
|
+
.sort((a, b) => (b.startTime > a.startTime ? 1 : -1))
|
|
180
|
+
.slice(0, 3);
|
|
181
|
+
groups.push({
|
|
182
|
+
signature: bucket.signature,
|
|
183
|
+
hash: bucket.hash,
|
|
184
|
+
count: bucket.count,
|
|
185
|
+
firstSeen: bucket.firstSeen,
|
|
186
|
+
lastSeen: bucket.lastSeen,
|
|
187
|
+
exampleTraceUrl: buildTraceUrl(opts.langfuseHost, opts.projectId, bucket.latestTraceId),
|
|
188
|
+
sampleSpans,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
groups.sort((a, b) => b.count - a.count);
|
|
192
|
+
return groups;
|
|
193
|
+
}
|
|
194
|
+
//# sourceMappingURL=error-signature.js.map
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal Langfuse HTTP client for querying traces and observations.
|
|
3
|
+
*
|
|
4
|
+
* Used by `ralph_hero__collate_debug` to fetch error spans emitted by the
|
|
5
|
+
* MCP server's OTel pipeline (see `telemetry.ts`). Authenticates via HTTP
|
|
6
|
+
* basic auth with `LANGFUSE_PUBLIC_KEY` and `LANGFUSE_SECRET_KEY`.
|
|
7
|
+
*
|
|
8
|
+
* No SDK dependency — uses Node's native `fetch` (Node 20+).
|
|
9
|
+
*
|
|
10
|
+
* Reference: https://langfuse.com/docs/api
|
|
11
|
+
*/
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Factory
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
const DEFAULT_HOST = "http://localhost:3100";
|
|
16
|
+
function buildAuthHeader(publicKey, secretKey) {
|
|
17
|
+
const credentials = `${publicKey}:${secretKey}`;
|
|
18
|
+
// Node 20+ provides global Buffer
|
|
19
|
+
const encoded = Buffer.from(credentials, "utf-8").toString("base64");
|
|
20
|
+
return `Basic ${encoded}`;
|
|
21
|
+
}
|
|
22
|
+
function appendQueryParams(url, params) {
|
|
23
|
+
if (!params)
|
|
24
|
+
return;
|
|
25
|
+
for (const [key, value] of Object.entries(params)) {
|
|
26
|
+
if (value === undefined || value === null)
|
|
27
|
+
continue;
|
|
28
|
+
url.searchParams.set(key, String(value));
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Create a Langfuse HTTP client.
|
|
33
|
+
*
|
|
34
|
+
* Throws on construction if `publicKey` or `secretKey` are missing (in args
|
|
35
|
+
* and in env), because every endpoint requires authentication.
|
|
36
|
+
*/
|
|
37
|
+
export function createLangfuseClient(options = {}) {
|
|
38
|
+
const host = (options.host ?? process.env.LANGFUSE_HOST ?? DEFAULT_HOST)
|
|
39
|
+
.replace(/\/+$/, "");
|
|
40
|
+
const publicKey = options.publicKey ?? process.env.LANGFUSE_PUBLIC_KEY;
|
|
41
|
+
const secretKey = options.secretKey ?? process.env.LANGFUSE_SECRET_KEY;
|
|
42
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
43
|
+
if (!publicKey || !secretKey) {
|
|
44
|
+
throw new Error("Langfuse credentials missing: set LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY (or pass via options).");
|
|
45
|
+
}
|
|
46
|
+
const authHeader = buildAuthHeader(publicKey, secretKey);
|
|
47
|
+
async function request(path, params) {
|
|
48
|
+
const url = new URL(`${host}${path}`);
|
|
49
|
+
appendQueryParams(url, params);
|
|
50
|
+
const response = await fetchImpl(url.toString(), {
|
|
51
|
+
method: "GET",
|
|
52
|
+
headers: {
|
|
53
|
+
Authorization: authHeader,
|
|
54
|
+
Accept: "application/json",
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
if (!response.ok) {
|
|
58
|
+
const bodyText = await response.text().catch(() => "");
|
|
59
|
+
throw new Error(`Langfuse request failed: ${response.status} ${response.statusText}` +
|
|
60
|
+
(bodyText ? ` — ${bodyText.slice(0, 200)}` : ""));
|
|
61
|
+
}
|
|
62
|
+
return (await response.json());
|
|
63
|
+
}
|
|
64
|
+
async function queryTraces(params = {}) {
|
|
65
|
+
return request("/api/public/traces", params);
|
|
66
|
+
}
|
|
67
|
+
async function queryObservations(params = {}) {
|
|
68
|
+
return request("/api/public/observations", params);
|
|
69
|
+
}
|
|
70
|
+
async function queryAllObservations(params = {}, maxPages = 10) {
|
|
71
|
+
const all = [];
|
|
72
|
+
const limit = params.limit ?? 100;
|
|
73
|
+
let page = params.page ?? 1;
|
|
74
|
+
for (let i = 0; i < maxPages; i++) {
|
|
75
|
+
const result = await queryObservations({ ...params, page, limit });
|
|
76
|
+
if (!result.data || result.data.length === 0)
|
|
77
|
+
break;
|
|
78
|
+
all.push(...result.data);
|
|
79
|
+
const totalPages = result.meta?.totalPages;
|
|
80
|
+
if (totalPages !== undefined && page >= totalPages)
|
|
81
|
+
break;
|
|
82
|
+
if (result.data.length < limit)
|
|
83
|
+
break;
|
|
84
|
+
page += 1;
|
|
85
|
+
}
|
|
86
|
+
return all;
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
host,
|
|
90
|
+
queryTraces,
|
|
91
|
+
queryObservations,
|
|
92
|
+
queryAllObservations,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
//# sourceMappingURL=langfuse-client.js.map
|
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* MCP tools for debug log collation and statistics.
|
|
3
3
|
*
|
|
4
|
-
* Provides
|
|
5
|
-
*
|
|
4
|
+
* Provides:
|
|
5
|
+
* - `ralph_hero__collate_debug` (v2 — queries Langfuse for error spans,
|
|
6
|
+
* groups by normalized signature, returns the grouped report; GitHub
|
|
7
|
+
* issue creation lands in Phase 3b / GH-1100)
|
|
8
|
+
* - `ralph_hero__debug_stats` (v1 — aggregates JSONL logs; preserved for
|
|
9
|
+
* backward compat, not extended)
|
|
6
10
|
*
|
|
7
|
-
* Only registered when RALPH_DEBUG=true
|
|
11
|
+
* Only registered when `RALPH_DEBUG=true`. JSONL helpers below still back
|
|
12
|
+
* `debug_stats`; the new Langfuse path is fully separate.
|
|
8
13
|
*/
|
|
9
14
|
import { readdir, readFile } from "node:fs/promises";
|
|
10
15
|
import { join } from "node:path";
|
|
@@ -13,6 +18,8 @@ import { createHash } from "node:crypto";
|
|
|
13
18
|
import { z } from "zod";
|
|
14
19
|
import { toolSuccess, toolError } from "../types.js";
|
|
15
20
|
import { zBoolish } from "../lib/zod-helpers.js";
|
|
21
|
+
import { createLangfuseClient, } from "../lib/langfuse-client.js";
|
|
22
|
+
import { groupSpansBySignature, observationToSpan, } from "../lib/error-signature.js";
|
|
16
23
|
// ---------------------------------------------------------------------------
|
|
17
24
|
// JSONL Parsing
|
|
18
25
|
// ---------------------------------------------------------------------------
|
|
@@ -174,123 +181,97 @@ export function aggregateStats(events, groupBy) {
|
|
|
174
181
|
groups,
|
|
175
182
|
};
|
|
176
183
|
}
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
184
|
+
let langfuseClientFactory = () => createLangfuseClient();
|
|
185
|
+
/**
|
|
186
|
+
* Override the Langfuse client factory. Returns a disposer that restores the
|
|
187
|
+
* previous factory (used by tests).
|
|
188
|
+
*/
|
|
189
|
+
export function setLangfuseClientFactory(factory) {
|
|
190
|
+
const prev = langfuseClientFactory;
|
|
191
|
+
langfuseClientFactory = factory;
|
|
192
|
+
return () => {
|
|
193
|
+
langfuseClientFactory = prev;
|
|
194
|
+
};
|
|
195
|
+
}
|
|
180
196
|
export function registerDebugTools(server, client) {
|
|
181
197
|
const logDir = join(homedir(), ".ralph-hero", "logs");
|
|
198
|
+
// `client` is referenced by `debug_stats` (legacy) and reserved for Phase 3b
|
|
199
|
+
// (GH-1100), which will use it for GitHub dedup + issue creation.
|
|
200
|
+
void client;
|
|
182
201
|
// -------------------------------------------------------------------------
|
|
183
|
-
// ralph_hero__collate_debug
|
|
202
|
+
// ralph_hero__collate_debug (v2 — Langfuse path)
|
|
184
203
|
// -------------------------------------------------------------------------
|
|
185
|
-
server.tool("ralph_hero__collate_debug", "
|
|
204
|
+
server.tool("ralph_hero__collate_debug", "Query Langfuse for error spans in a time window, normalize messages, and group by signature. Phase 3a returns the grouped report only (dryRun forced true); Phase 3b (GH-1100) adds GitHub issue dedup + create/comment. Returns: { since, errorGroups, totalOccurrences, dryRun, groups[] }.", {
|
|
186
205
|
since: z
|
|
187
206
|
.string()
|
|
188
207
|
.optional()
|
|
189
|
-
.describe("ISO date string. Only
|
|
208
|
+
.describe("ISO date string. Only spans whose startTime >= this value are considered (default: 24h ago)."),
|
|
190
209
|
dryRun: zBoolish()
|
|
191
210
|
.optional()
|
|
192
|
-
.default(
|
|
193
|
-
.describe("
|
|
211
|
+
.default(true)
|
|
212
|
+
.describe("Phase 3a only honors dryRun=true; passing false returns a stub error until Phase 3b lands."),
|
|
213
|
+
minOccurrences: z
|
|
214
|
+
.number()
|
|
215
|
+
.int()
|
|
216
|
+
.min(1)
|
|
217
|
+
.optional()
|
|
218
|
+
.default(3)
|
|
219
|
+
.describe("Filter out signatures with fewer occurrences (default: 3)."),
|
|
194
220
|
projectNumber: z
|
|
195
221
|
.number()
|
|
196
222
|
.optional()
|
|
197
|
-
.describe("Project number override (
|
|
223
|
+
.describe("Project number override (reserved for Phase 3b)."),
|
|
198
224
|
}, async (args) => {
|
|
199
225
|
try {
|
|
226
|
+
const dryRun = args.dryRun ?? true;
|
|
227
|
+
if (!dryRun) {
|
|
228
|
+
return toolError("dryRun=false requires GH-1100 (Phase 3b) — not yet implemented");
|
|
229
|
+
}
|
|
230
|
+
const minOccurrences = args.minOccurrences ?? 3;
|
|
200
231
|
const sinceDate = args.since
|
|
201
232
|
? new Date(args.since)
|
|
202
233
|
: new Date(Date.now() - 24 * 60 * 60 * 1000);
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
if (errorGroups.length === 0) {
|
|
206
|
-
return toolSuccess({
|
|
207
|
-
message: "No errors found in the specified time window.",
|
|
208
|
-
sessionsAnalyzed,
|
|
209
|
-
since: sinceDate.toISOString(),
|
|
210
|
-
});
|
|
234
|
+
if (Number.isNaN(sinceDate.getTime())) {
|
|
235
|
+
return toolError(`Invalid 'since' value: ${args.since}`);
|
|
211
236
|
}
|
|
212
|
-
let
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
const owner = client.config.owner;
|
|
216
|
-
const repo = client.config.repo;
|
|
217
|
-
for (const group of errorGroups) {
|
|
218
|
-
totalOccurrences += group.count;
|
|
219
|
-
if (args.dryRun)
|
|
220
|
-
continue;
|
|
221
|
-
if (!owner || !repo) {
|
|
222
|
-
return toolError("RALPH_GH_OWNER and RALPH_GH_REPO must be set for issue creation");
|
|
223
|
-
}
|
|
224
|
-
// Search for existing issue with this hash
|
|
225
|
-
const searchQuery = `repo:${owner}/${repo} is:issue is:open label:debug-auto "${group.hash}" in:body`;
|
|
226
|
-
let existingIssueNumber;
|
|
227
|
-
try {
|
|
228
|
-
const searchResult = await client.query(`query SearchDebugIssues($q: String!) {
|
|
229
|
-
search(query: $q, type: ISSUE, first: 1) {
|
|
230
|
-
nodes {
|
|
231
|
-
... on Issue { number }
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
}`, { q: searchQuery });
|
|
235
|
-
existingIssueNumber = searchResult.search.nodes[0]?.number;
|
|
236
|
-
}
|
|
237
|
-
catch {
|
|
238
|
-
// Search failed, treat as no existing issue
|
|
239
|
-
}
|
|
240
|
-
if (existingIssueNumber) {
|
|
241
|
-
// Add occurrence comment
|
|
242
|
-
await client.mutate(`mutation AddComment($subjectId: ID!, $body: String!) {
|
|
243
|
-
addComment(input: { subjectId: $subjectId, body: $body }) {
|
|
244
|
-
commentEdge { node { id } }
|
|
245
|
-
}
|
|
246
|
-
}`, {
|
|
247
|
-
subjectId: `issue:${existingIssueNumber}`,
|
|
248
|
-
body: `## Occurrence Report\n\n- Count: ${group.count}\n- Period: ${group.firstSeen} — ${group.lastSeen}\n- Signature: \`${group.signature}\``,
|
|
249
|
-
}).catch(() => {
|
|
250
|
-
// Best-effort comment
|
|
251
|
-
});
|
|
252
|
-
issuesUpdated++;
|
|
253
|
-
}
|
|
254
|
-
else {
|
|
255
|
-
// Create new issue
|
|
256
|
-
try {
|
|
257
|
-
await client.mutate(`mutation CreateIssue($repoId: ID!, $title: String!, $body: String!) {
|
|
258
|
-
createIssue(input: { repositoryId: $repoId, title: $title, body: $body }) {
|
|
259
|
-
issue { number }
|
|
260
|
-
}
|
|
261
|
-
}`, {
|
|
262
|
-
repoId: `placeholder`, // Would need actual repo ID
|
|
263
|
-
title: `[debug-auto] ${getEventName(group.sample)} ${getErrorType(group.sample)}`,
|
|
264
|
-
body: `## Debug Auto-Report\n\n**Hash**: \`${group.hash}\`\n**Signature**: \`${group.signature}\`\n**Occurrences**: ${group.count}\n**First seen**: ${group.firstSeen}\n**Last seen**: ${group.lastSeen}\n\n### Sample Error\n\n\`\`\`json\n${JSON.stringify(group.sample, null, 2)}\n\`\`\`\n\n---\n_Auto-generated by ralph_hero__collate_debug_`,
|
|
265
|
-
}).catch(() => {
|
|
266
|
-
// Best-effort issue creation
|
|
267
|
-
});
|
|
268
|
-
issuesCreated++;
|
|
269
|
-
}
|
|
270
|
-
catch {
|
|
271
|
-
// Skip failed creations
|
|
272
|
-
}
|
|
273
|
-
}
|
|
237
|
+
let langfuse;
|
|
238
|
+
try {
|
|
239
|
+
langfuse = langfuseClientFactory();
|
|
274
240
|
}
|
|
241
|
+
catch (error) {
|
|
242
|
+
return toolError(`Langfuse client unavailable: ${error instanceof Error ? error.message : String(error)}`);
|
|
243
|
+
}
|
|
244
|
+
const fromStartTime = sinceDate.toISOString();
|
|
245
|
+
const observations = await langfuse.queryAllObservations({
|
|
246
|
+
type: "SPAN",
|
|
247
|
+
level: "ERROR",
|
|
248
|
+
fromStartTime,
|
|
249
|
+
limit: 100,
|
|
250
|
+
});
|
|
251
|
+
const spans = observations.map(observationToSpan);
|
|
252
|
+
const groups = groupSpansBySignature(spans, {
|
|
253
|
+
minOccurrences,
|
|
254
|
+
langfuseHost: langfuse.host,
|
|
255
|
+
});
|
|
256
|
+
const totalOccurrences = groups.reduce((sum, g) => sum + g.count, 0);
|
|
275
257
|
return toolSuccess({
|
|
276
|
-
since:
|
|
277
|
-
|
|
278
|
-
errorGroups: errorGroups.length,
|
|
258
|
+
since: fromStartTime,
|
|
259
|
+
errorGroups: groups.length,
|
|
279
260
|
totalOccurrences,
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
dryRun: args.dryRun,
|
|
283
|
-
groups: errorGroups.map((g) => ({
|
|
284
|
-
hash: g.hash,
|
|
261
|
+
dryRun: true,
|
|
262
|
+
groups: groups.map((g) => ({
|
|
285
263
|
signature: g.signature,
|
|
264
|
+
hash: g.hash,
|
|
286
265
|
count: g.count,
|
|
287
266
|
firstSeen: g.firstSeen,
|
|
288
267
|
lastSeen: g.lastSeen,
|
|
268
|
+
exampleTraceUrl: g.exampleTraceUrl,
|
|
269
|
+
sampleSpans: g.sampleSpans.slice(0, 3),
|
|
289
270
|
})),
|
|
290
271
|
});
|
|
291
272
|
}
|
|
292
273
|
catch (error) {
|
|
293
|
-
return toolError(`Failed to collate debug
|
|
274
|
+
return toolError(`Failed to collate debug spans: ${error instanceof Error ? error.message : String(error)}`);
|
|
294
275
|
}
|
|
295
276
|
});
|
|
296
277
|
// -------------------------------------------------------------------------
|