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.
- package/dist/github-client.js +101 -37
- package/dist/index.js +16 -0
- package/dist/lib/error-signature.js +194 -0
- package/dist/lib/langfuse-client.js +95 -0
- package/dist/lib/telemetry.js +156 -0
- package/dist/lib/workflow-states.js +1 -1
- package/dist/tools/debug-tools.js +72 -91
- package/dist/tools/issue-tools.js +3 -3
- package/package.json +7 -1
package/dist/github-client.js
CHANGED
|
@@ -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
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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
|
// -------------------------------------------------------------------------
|
|
@@ -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
|
-
.
|
|
72
|
-
|
|
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.
|
|
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
|
},
|