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.
@@ -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 `ralph_hero__collate_debug` (error grouping + GitHub issue creation)
5
- * and `ralph_hero__debug_stats` (tool call aggregation metrics).
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. Reads JSONL logs written by DebugLogger.
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
- // Register Debug Tools
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", "Collate debug log errors into GitHub issues. Reads JSONL logs, groups errors by normalized signature, deduplicates against existing `debug-auto` labeled issues, and creates/updates issues. Returns: summary of issues created, updated, and total occurrences.", {
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 process events after this time (default: 24h ago)"),
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(false)
193
- .describe("If true, report what would be created/updated without making changes"),
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 (defaults to configured project)"),
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
- const { events, sessionsAnalyzed } = await readLogEvents(logDir, sinceDate);
204
- const errorGroups = groupErrors(events);
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 issuesCreated = 0;
213
- let issuesUpdated = 0;
214
- let totalOccurrences = 0;
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: sinceDate.toISOString(),
277
- sessionsAnalyzed,
278
- errorGroups: errorGroups.length,
258
+ since: fromStartTime,
259
+ errorGroups: groups.length,
279
260
  totalOccurrences,
280
- issuesCreated: args.dryRun ? 0 : issuesCreated,
281
- issuesUpdated: args.dryRun ? 0 : issuesUpdated,
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 logs: ${error instanceof Error ? error.message : String(error)}`);
274
+ return toolError(`Failed to collate debug spans: ${error instanceof Error ? error.message : String(error)}`);
294
275
  }
295
276
  });
296
277
  // -------------------------------------------------------------------------
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ralph-hero-mcp-server",
3
- "version": "2.5.139",
3
+ "version": "2.5.140",
4
4
  "description": "MCP server for GitHub Projects V2 - Ralph workflow automation",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",