ralph-hero-mcp-server 2.5.130 → 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.
@@ -6,9 +6,33 @@
6
6
  * rateLimit fragment for continuous tracking.
7
7
  */
8
8
  import { graphql } from "@octokit/graphql";
9
+ import { trace, SpanStatusCode } from "@opentelemetry/api";
9
10
  import { RateLimiter } from "./lib/rate-limiter.js";
10
11
  import { SessionCache } from "./lib/cache.js";
11
12
  import { extractOperationName, sanitize } from "./lib/debug-logger.js";
13
+ /**
14
+ * Classify a GraphQL error into one of: "rate_limit" | "network" | "graphql".
15
+ *
16
+ * - `rate_limit` — HTTP 403 with a `retry-after` header (GitHub's secondary
17
+ * rate limit signal). Plain 403s without retry-after fall through to
18
+ * `graphql` since they're more commonly permission errors.
19
+ * - `network` — no `status` field on the error (fetch-level failure, DNS,
20
+ * socket reset, etc.)
21
+ * - `graphql` — everything else (GraphQL validation errors, 4xx, 5xx).
22
+ */
23
+ function classifyGraphQLError(error) {
24
+ if (!error || typeof error !== "object") {
25
+ return "graphql";
26
+ }
27
+ const e = error;
28
+ if (typeof e.status !== "number") {
29
+ return "network";
30
+ }
31
+ if (e.status === 403 && e.headers?.["retry-after"]) {
32
+ return "rate_limit";
33
+ }
34
+ return "graphql";
35
+ }
12
36
  /**
13
37
  * The rateLimit fragment to include in every query for proactive tracking.
14
38
  */
@@ -44,6 +68,11 @@ export function createGitHubClient(clientConfig, debugLogger) {
44
68
  const cache = new SessionCache();
45
69
  /**
46
70
  * Execute a raw GraphQL request and handle rate limit tracking.
71
+ *
72
+ * Wraps the request in a `ralph_hero.graphql` OpenTelemetry span when a
73
+ * tracer is available. When `RALPH_DEBUG` is unset and the SDK has not been
74
+ * initialized, `@opentelemetry/api` returns a no-op tracer/span — calls are
75
+ * essentially free.
47
76
  */
48
77
  async function executeGraphQL(queryString, variables, graphqlFn = graphqlWithAuth) {
49
78
  await rateLimiter.checkBeforeRequest();
@@ -62,53 +91,88 @@ export function createGitHubClient(clientConfig, debugLogger) {
62
91
  fullQuery.slice(insertPos);
63
92
  }
64
93
  }
65
- const t0 = Date.now();
66
- try {
67
- const response = await graphqlFn(fullQuery, variables || {});
68
- // Update rate limit tracker from response
69
- if (response && typeof response === "object" && "rateLimit" in response) {
70
- const rl = response.rateLimit;
71
- if (rl) {
72
- rateLimiter.update(rl);
94
+ const tracer = trace.getTracer("ralph-hero");
95
+ const operation = extractOperationName(fullQuery);
96
+ return tracer.startActiveSpan("ralph_hero.graphql", async (span) => {
97
+ if (operation) {
98
+ span.setAttribute("ralph_hero.operation", operation);
99
+ }
100
+ const t0 = Date.now();
101
+ try {
102
+ const response = await graphqlFn(fullQuery, variables || {});
103
+ // Update rate limit tracker from response
104
+ if (response && typeof response === "object" && "rateLimit" in response) {
105
+ const rl = response.rateLimit;
106
+ if (rl) {
107
+ rateLimiter.update(rl);
108
+ if (typeof rl.remaining === "number") {
109
+ span.setAttribute("ralph_hero.rate_limit.remaining", rl.remaining);
110
+ }
111
+ if (typeof rl.cost === "number") {
112
+ span.setAttribute("ralph_hero.rate_limit.cost", rl.cost);
113
+ }
114
+ }
73
115
  }
116
+ debugLogger?.logGraphQL({
117
+ operation,
118
+ variables: sanitize(variables),
119
+ durationMs: Date.now() - t0,
120
+ status: 200,
121
+ rateLimitRemaining: response
122
+ .rateLimit?.remaining,
123
+ rateLimitCost: response.rateLimit
124
+ ?.cost,
125
+ });
126
+ return response;
74
127
  }
75
- debugLogger?.logGraphQL({
76
- operation: extractOperationName(fullQuery),
77
- variables: sanitize(variables),
78
- durationMs: Date.now() - t0,
79
- status: 200,
80
- rateLimitRemaining: response.rateLimit?.remaining,
81
- rateLimitCost: response.rateLimit?.cost,
82
- });
83
- return response;
84
- }
85
- catch (error) {
86
- debugLogger?.logGraphQL({
87
- operation: extractOperationName(fullQuery),
88
- variables: sanitize(variables),
89
- durationMs: Date.now() - t0,
90
- status: error && typeof error === "object" && "status" in error
91
- ? error.status
92
- : 500,
93
- error: error instanceof Error ? error.message : String(error),
94
- });
95
- // Handle rate limit errors (403)
96
- if (error &&
97
- typeof error === "object" &&
98
- "status" in error &&
99
- error.status === 403) {
100
- const retryAfter = error && typeof error === "object" && "headers" in error
128
+ catch (error) {
129
+ // Detect rate-limit retry-able case FIRST. On the retry path we
130
+ // intentionally do NOT mark this span ERROR (or log a 500-shaped
131
+ // entry) the retry may succeed and we don't want Langfuse to
132
+ // show a permanently-failed parent for a request that eventually
133
+ // returned 200. Only the non-retry path mutates span status.
134
+ const is403 = error &&
135
+ typeof error === "object" &&
136
+ "status" in error &&
137
+ error.status === 403;
138
+ const retryAfter = is403 && error && typeof error === "object" && "headers" in error
101
139
  ? error.headers?.["retry-after"]
102
140
  : undefined;
103
141
  if (retryAfter) {
104
142
  const waitMs = parseInt(retryAfter, 10) * 1000;
105
143
  console.error(`[github-client] Rate limited. Waiting ${retryAfter}s before retry.`);
106
144
  await new Promise((resolve) => setTimeout(resolve, waitMs));
107
- return executeGraphQL(queryString, variables, graphqlFn);
145
+ // `await` is critical: in an async fn, `finally { span.end() }`
146
+ // runs as soon as the return expression evaluates. Without
147
+ // `await`, the inner Promise would still be pending while
148
+ // `span.end()` fires, exporting a half-finished outer span.
149
+ return await executeGraphQL(queryString, variables, graphqlFn);
108
150
  }
151
+ // Non-retry error path: mark span ERROR, log, rethrow.
152
+ const errorType = classifyGraphQLError(error);
153
+ span.setAttribute("ralph_hero.error_type", errorType);
154
+ span.setStatus({
155
+ code: SpanStatusCode.ERROR,
156
+ message: error instanceof Error ? error.message : String(error),
157
+ });
158
+ if (error instanceof Error) {
159
+ span.recordException(error);
160
+ }
161
+ debugLogger?.logGraphQL({
162
+ operation,
163
+ variables: sanitize(variables),
164
+ durationMs: Date.now() - t0,
165
+ status: error && typeof error === "object" && "status" in error
166
+ ? error.status
167
+ : 500,
168
+ error: error instanceof Error ? error.message : String(error),
169
+ });
170
+ throw error;
109
171
  }
110
- throw error;
111
- }
172
+ finally {
173
+ span.end();
174
+ }
175
+ });
112
176
  }
113
177
  return {
114
178
  config: clientConfig,
package/dist/index.js CHANGED
@@ -14,6 +14,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
14
14
  import { createGitHubClient } from "./github-client.js";
15
15
  import { FieldOptionCache } from "./lib/cache.js";
16
16
  import { createDebugLogger, wrapServerToolWithLogging } from "./lib/debug-logger.js";
17
+ import { initTelemetry } from "./lib/telemetry.js";
17
18
  import { toolSuccess, resolveProjectOwner } from "./types.js";
18
19
  import { resolveRepoFromProject } from "./lib/helpers.js";
19
20
  import { detectOrphanRepoIssues } from "./lib/health.js";
@@ -360,6 +361,21 @@ function registerCoreTools(server, client) {
360
361
  */
361
362
  async function main() {
362
363
  console.error("[ralph-hero] Starting MCP server...");
364
+ // OTel SDK init MUST happen before initGitHubClient so the first GraphQL
365
+ // call from the client (repo inference) is captured as a span. initTelemetry
366
+ // returns null when RALPH_DEBUG !== "true" — no SDK objects allocated and
367
+ // no exporter threads in that path.
368
+ const sdk = (await initTelemetry());
369
+ if (sdk) {
370
+ console.error("[ralph-hero] OTel telemetry enabled");
371
+ // Best-effort flush on graceful shutdown. Errors swallowed because by the
372
+ // time SIGTERM fires we're already on the way out — partial trace loss is
373
+ // acceptable. SIGINT is not wired because Claude Code's stdio transport
374
+ // already cleans up on EOF.
375
+ process.on("SIGTERM", () => {
376
+ void sdk.shutdown().catch(() => undefined);
377
+ });
378
+ }
363
379
  const debugLogger = createDebugLogger();
364
380
  if (debugLogger) {
365
381
  console.error("[ralph-hero] Debug logging enabled (RALPH_DEBUG=true)");
@@ -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
@@ -0,0 +1,156 @@
1
+ /**
2
+ * OpenTelemetry initialization for the ralph-hero MCP server.
3
+ *
4
+ * Lazy-initialized when `RALPH_DEBUG=true`. When the env var is unset or any
5
+ * value other than the literal string `"true"`, `initTelemetry()` returns
6
+ * `null` and no OpenTelemetry SDK objects are constructed — zero overhead.
7
+ *
8
+ * The OTLP HTTP exporter reads its endpoint from `OTEL_EXPORTER_OTLP_ENDPOINT`
9
+ * (standard OTel convention). Auto-instrumentation is explicitly OFF — only
10
+ * the explicit `ralph_hero.graphql` spans emitted from `github-client.ts`
11
+ * appear in the resulting trace.
12
+ *
13
+ * A custom `SpanProcessor` redacts token-shaped attribute values at span
14
+ * start so secrets never reach the exporter. See `redactTokenAttributes()`.
15
+ */
16
+ import { readFileSync } from "node:fs";
17
+ import { fileURLToPath } from "node:url";
18
+ import { dirname, resolve } from "node:path";
19
+ /**
20
+ * Attribute value matching `^gh[ps]_` (GitHub PAT/server-to-server token shape)
21
+ * and key matching `_TOKEN$` (case-insensitive) or `^authorization$` are
22
+ * replaced with this sentinel before the span is exported.
23
+ */
24
+ const REDACTED = "[REDACTED]";
25
+ const TOKEN_VALUE_RE = /^gh[ps]_/;
26
+ const TOKEN_KEY_RE = /(_TOKEN$|^authorization$)/i;
27
+ /**
28
+ * Pure function — exported for unit tests. Returns a shallow copy of `attrs`
29
+ * with any token-shaped value or key replaced by `[REDACTED]`.
30
+ *
31
+ * Keys are matched case-insensitively against `_TOKEN$` and `^authorization$`.
32
+ * Values are matched (when they are strings) against `^gh[ps]_`.
33
+ *
34
+ * Non-matching attributes (including non-string values like numbers and
35
+ * booleans) pass through unchanged.
36
+ */
37
+ export function redactTokenAttributes(attrs) {
38
+ if (!attrs)
39
+ return {};
40
+ const out = {};
41
+ for (const [key, value] of Object.entries(attrs)) {
42
+ if (TOKEN_KEY_RE.test(key)) {
43
+ out[key] = REDACTED;
44
+ continue;
45
+ }
46
+ if (typeof value === "string" && TOKEN_VALUE_RE.test(value)) {
47
+ out[key] = REDACTED;
48
+ continue;
49
+ }
50
+ out[key] = value;
51
+ }
52
+ return out;
53
+ }
54
+ /**
55
+ * SpanProcessor that scrubs token-shaped attributes from each span.
56
+ *
57
+ * The scrub runs on `onEnd` rather than `onStart` because we need to see the
58
+ * full set of attributes that any caller has set on the span. Crucially, once
59
+ * a span has ended, `span.setAttribute()` is a documented no-op — the only
60
+ * way to mutate the final exported attribute set is to write directly to the
61
+ * `attributes` object. TypeScript types it as readonly but the runtime
62
+ * representation is a plain mutable object owned by the span instance.
63
+ *
64
+ * Order matters: this processor must be registered BEFORE the exporting
65
+ * processor (`BatchSpanProcessor` or `SimpleSpanProcessor`) so the mutation
66
+ * is visible by the time the export call reads `attributes`.
67
+ */
68
+ export class TokenScrubbingSpanProcessor {
69
+ onStart(_span, _parentContext) {
70
+ // No-op — attributes set on an active span go through `setAttribute`,
71
+ // not the readable snapshot. We catch them all in `onEnd`.
72
+ }
73
+ onEnd(span) {
74
+ const attrs = span.attributes;
75
+ if (!attrs)
76
+ return;
77
+ // attrs is `Attributes` (readonly per the type) but mutable at runtime.
78
+ // Mutate in-place so downstream processors see the redacted values.
79
+ const mut = attrs;
80
+ for (const [key, value] of Object.entries(mut)) {
81
+ if (TOKEN_KEY_RE.test(key)) {
82
+ mut[key] = REDACTED;
83
+ }
84
+ else if (typeof value === "string" && TOKEN_VALUE_RE.test(value)) {
85
+ mut[key] = REDACTED;
86
+ }
87
+ }
88
+ }
89
+ async shutdown() {
90
+ // No-op — this processor holds no resources.
91
+ }
92
+ async forceFlush() {
93
+ // No-op — this processor performs no async work.
94
+ }
95
+ }
96
+ /**
97
+ * Read the MCP server semver from package.json next to this module.
98
+ *
99
+ * Falls back to `"unknown"` if the file is missing or unreadable so the SDK
100
+ * still starts up — the version is informational, not load-bearing.
101
+ */
102
+ function resolveServiceVersion() {
103
+ try {
104
+ // In ESM, __dirname isn't defined; compute it from import.meta.url.
105
+ const here = dirname(fileURLToPath(import.meta.url));
106
+ // Walk up from src/lib (or dist/lib at runtime) to the package root.
107
+ const pkgPath = resolve(here, "..", "..", "package.json");
108
+ const raw = readFileSync(pkgPath, "utf8");
109
+ const pkg = JSON.parse(raw);
110
+ return pkg.version ?? "unknown";
111
+ }
112
+ catch {
113
+ return "unknown";
114
+ }
115
+ }
116
+ /**
117
+ * Initialize the OpenTelemetry NodeSDK when `RALPH_DEBUG=true`.
118
+ *
119
+ * - Returns `null` (zero overhead) when `process.env.RALPH_DEBUG !== "true"`.
120
+ * - When enabled: configures an OTLP/HTTP trace exporter, no auto-instrumentation,
121
+ * a `TokenScrubbingSpanProcessor` ahead of the default batch processor, and
122
+ * resource attrs `service.name = "ralph-hero"`, `service.version = <semver>`.
123
+ *
124
+ * Caller is responsible for calling `sdk.shutdown()` (e.g., on SIGTERM) to
125
+ * flush in-flight spans.
126
+ */
127
+ export async function initTelemetry() {
128
+ if (process.env.RALPH_DEBUG !== "true") {
129
+ return null;
130
+ }
131
+ // Dynamic imports keep zero-overhead in the disabled path — when RALPH_DEBUG
132
+ // is unset, none of these modules are loaded into memory.
133
+ const { NodeSDK } = await import("@opentelemetry/sdk-node");
134
+ const { OTLPTraceExporter } = await import("@opentelemetry/exporter-trace-otlp-http");
135
+ const { Resource } = await import("@opentelemetry/resources");
136
+ const { SEMRESATTRS_SERVICE_NAME, SEMRESATTRS_SERVICE_VERSION, } = await import("@opentelemetry/semantic-conventions");
137
+ const { BatchSpanProcessor } = await import("@opentelemetry/sdk-trace-base");
138
+ const endpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT ??
139
+ "http://localhost:3100/api/public/otel/v1/traces";
140
+ const exporter = new OTLPTraceExporter({ url: endpoint });
141
+ const sdk = new NodeSDK({
142
+ resource: new Resource({
143
+ [SEMRESATTRS_SERVICE_NAME]: "ralph-hero",
144
+ [SEMRESATTRS_SERVICE_VERSION]: resolveServiceVersion(),
145
+ }),
146
+ spanProcessors: [
147
+ new TokenScrubbingSpanProcessor(),
148
+ new BatchSpanProcessor(exporter),
149
+ ],
150
+ // No auto-instrumentation — only explicit ralph_hero.* spans are emitted.
151
+ instrumentations: [],
152
+ });
153
+ sdk.start();
154
+ return sdk;
155
+ }
156
+ //# sourceMappingURL=telemetry.js.map
@@ -129,6 +129,6 @@ export const WORKFLOW_STATE_TO_STATUS = {
129
129
  "In Review": "In Progress",
130
130
  "Done": "Done",
131
131
  "Canceled": "Done",
132
- "Human Needed": "Done",
132
+ "Human Needed": "Todo",
133
133
  };
134
134
  //# sourceMappingURL=workflow-states.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
  // -------------------------------------------------------------------------
@@ -25,7 +25,7 @@ export function registerIssueTools(server, client, fieldCache) {
25
25
  // -------------------------------------------------------------------------
26
26
  // ralph_hero__list_issues
27
27
  // -------------------------------------------------------------------------
28
- server.tool("ralph_hero__list_issues", "List issues from a GitHub repository with optional filters. Fetches all project items (full project scan, no silent 500-cap) and applies filters client-side, so items at any board position are visible regardless of default ordering. Returns: number, title, state, workflowState, estimate, priority, iteration, labels, assignees. Use workflowState filter to find issues in a specific phase. Use iteration filter with @current/@next or sprint title. Recovery: if no results, broaden filters or check that issues exist in the project.", {
28
+ server.tool("ralph_hero__list_issues", "List issues from a GitHub repository with optional filters. Fetches all project items (full project scan, no silent 500-cap) and applies filters client-side, so items at any board position are visible regardless of default ordering. By default returns issues in any state (both OPEN and CLOSED) so visibility matches the dashboard family (pipeline_dashboard, next_actions, project_hygiene); pass the `state` parameter (\"OPEN\" or \"CLOSED\") to narrow. Returns: number, title, state, workflowState, estimate, priority, iteration, labels, assignees. Use workflowState filter to find issues in a specific phase. Use iteration filter with @current/@next or sprint title. Recovery: if no results, broaden filters or check that issues exist in the project.", {
29
29
  owner: z
30
30
  .string()
31
31
  .optional()
@@ -68,8 +68,8 @@ export function registerIssueTools(server, client, fieldCache) {
68
68
  state: z
69
69
  .enum(["OPEN", "CLOSED"])
70
70
  .optional()
71
- .default("OPEN")
72
- .describe("Issue state filter (default: OPEN)"),
71
+ .describe("Issue state filter. When omitted, returns issues in any state " +
72
+ "(matches dashboard-family behavior). Pass 'OPEN' or 'CLOSED' to narrow."),
73
73
  reason: z
74
74
  .enum(["completed", "not_planned", "reopened"])
75
75
  .optional()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ralph-hero-mcp-server",
3
- "version": "2.5.130",
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",
@@ -20,6 +20,12 @@
20
20
  "@modelcontextprotocol/sdk": "^1.26.0",
21
21
  "@octokit/graphql": "^9.0.3",
22
22
  "@octokit/plugin-paginate-graphql": "^6.0.0",
23
+ "@opentelemetry/api": "^1.9.0",
24
+ "@opentelemetry/exporter-trace-otlp-http": "^0.57.0",
25
+ "@opentelemetry/resources": "^1.30.0",
26
+ "@opentelemetry/sdk-node": "^0.57.0",
27
+ "@opentelemetry/sdk-trace-base": "^1.30.0",
28
+ "@opentelemetry/semantic-conventions": "^1.28.0",
23
29
  "yaml": "^2.7.0",
24
30
  "zod": "^3.25.0"
25
31
  },