Skip to content

fix(telemetry): close all OTel/Langfuse gaps, add full proxy error logging, increase retry limit - #962

Merged
murdore merged 1 commit into
releasefrom
fix/telemetry-proxy-observability
Apr 18, 2026
Merged

murdore merged 1 commit into
releasefrom
fix/telemetry-proxy-observability

Conversation

@murdore

@murdore murdore commented Apr 17, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • Close 28/28 identified telemetry gaps across Pipeline A (Vercel AI SDK → OTel → Langfuse) and Pipeline B (SpanSerializer → MetricsAggregator → Langfuse API)
  • Add full raw response logging (headers + body + OTel trace) for all non-200 proxy upstream responses including 429s and auth-retry errors
  • Increase proxy per-account 429 retry limit from 5 → 10 (11 total attempts per account before rotating)

Telemetry gaps resolved (28/28)

Foundation — Pipeline B → OTel linkage

Gap Fix
A1 createMetricsTraceContext reads active OTel span instead of random UUID
A3 Dead metricsSpan removed from BaseProvider
A4 SpanSerializer.createSpan inherits traceId via getActiveTraceContext()

Tool telemetry (T1–T10)

  • Renamed duplicate span, ERROR status on isError:true, error arg propagation in emitToolEndEvent, circuit breaker error propagation, retry count attribute
  • Coverage: discoverTools, RAG search, memory retrieval, request batcher — all wrapped with withSpan

Generate pipeline (G2–G7)

  • content-filter/length finish reasons → WARNING level, step count attributes, retry count, ContextBudgetExceededError classification

Stream pipeline (S1–S8)

  • stream:error event emission, tool:end from onStepFinish for all 13 providers, finishReason recording, NoOutputGeneratedError sentinel chunk, abort distinction, duplicate span removal

Provider fixes (P1–P8)

  • Error span propagation (Anthropic, OpenAI), error type classification, Bedrock ThrottlingException → RateLimitError, Gemini 3 + Bedrock generation:end with real token counts, Langfuse level/status enrichment

Full subsystem OTel coverage

All 13 providers, CLI turns, context/summarization, evaluation, file processing, RAG pipeline (chunker/reranker/vector), all server routes, middleware, workflow (sync + streaming), auth token store, HTTP/SSE/WS clients, memory retrieval, MCP subsystems

Langfuse enrichment (ContextEnricher.onEnd)

langfuse.usage_details, gen_ai.response.model, gen_ai.request.model_parameters, langfuse.level ERROR/WARNING

Proxy improvements

Full raw error logging for all non-200 responses

429 path in fetchAnthropicAccountResponse and all auth-retry error paths now capture:

  • All response headers → tracer.logUpstreamResponseHeaders()
  • Full response body → tracer.logUpstreamResponseBody()
  • Disk snapshot → logProxyBody({ phase: "upstream_response", ... })
  • Rate limit status in log line

Retry limit

MAX_RATE_LIMIT_SAME_ACCOUNT_RETRIES: 5 → 10

New files

  • src/lib/telemetry/traceContext.ts — getActiveTraceContext() helper
  • src/lib/utils/toolEndEmitter.ts — shared emitToolEndFromStepFinish() helper
  • test/telemetry-gaps-verification.ts — 28-test verification suite
  • docs/telemetry-gaps-audit.md — full audit document
  • docs/telemetry-fix-plan.md — detailed fix specifications
  • docs/telemetry-fix-completion-report.md — executive summary

Test plan

  • TypeScript: 0 errors across 3637 files
  • Telemetry gaps: 0/28 confirmed (all resolved)
  • Regression tests: 48/48 pass
  • Build: passes all pre-commit hooks (check, format, lint, validate, security, build)
  • Smoke test: deploy and verify Langfuse traces show correct hierarchy, levels, and usage

Summary by CodeRabbit

  • New Features

    • Broad telemetry/tracing across SDK and automatic emission of tool-completion and generation events; streams now emit a sentinel chunk on no-output.
  • Bug Fixes

    • Closed 26 of 28 telemetry gaps; improved error classification, finish-reason handling, retry/abort attribution, fallback emissions, and deduplication of generation observations.
  • Documentation

    • Added audit, gap list, remediation plan, execution plan, diffs, completion report, and proof artifacts.
  • Tests

    • Added an executable telemetry verification suite.

Copilot AI review requested due to automatic review settings April 17, 2026 03:52
@vercel

vercel Bot commented Apr 17, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
neurolink Ready Ready Preview, Comment Apr 18, 2026 10:46am

@github-actions

github-actions Bot commented Apr 17, 2026 •

Copy link
Copy Markdown
Contributor

✅ Single Commit Policy - COMPLIANT

Status: Policy requirements met • 1 commit • Valid format • Ready for merge

📊 View validation details

📝 Commit Details

  • Hash: d7713e979219c014f04688b363caaea2df099ab2
  • Message: refactor(types): close OTel gaps, consolidate 262 scattered types, harden proxy retry
  • Author: Sachin Sharma

✅ Validation Results

  • Single commit requirement met
  • No merge commits in branch
  • Semantic commit message format verified
  • Ready for squash merge to release branch

🤖 Automated validation by NeuroLink Single Commit Enforcement

@coderabbitai

coderabbitai Bot commented Apr 17, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 6847964c-dfd0-4c17-81f6-65b0925e594e

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Walkthrough

Adds widespread OpenTelemetry instrumentation, trace-context propagation, tool-end/generation event emission utilities, telemetry verification tests, and comprehensive telemetry documentation/plans across providers, server routes, workflow, RAG/memory, clients, auth, and core runtime. (32 words)

Changes

Cohort / File(s) Summary
Docs — audit, plans & proofs
docs/telemetry-gaps-audit.md, docs/telemetry-fix-plan.md, docs/telemetry-fix-execution-plan.md, docs/telemetry-fix-completion-report.md, docs/telemetry-fix-diff.txt, docs/telemetry-baseline-proof.txt, docs/telemetry-after-proof.txt
Adds telemetry audit, multi‑phase fix plan, execution plan, completion report, diffs, and before/after proof artifacts capturing verification runs and CI outputs.
Verification test
test/telemetry-gaps-verification.ts
New executable verification suite that inspects source patterns and runtime mappings to PASS/FAIL/SKIP each telemetry gap.
Telemetry core & types
src/lib/telemetry/traceContext.ts, src/lib/telemetry/tracers.ts, src/lib/types/span.ts
New getActiveTraceContext, added tracers.auth/tracers.workflow, and SpanStatus.WARNING enum member.
Span serialization & enrichment
src/lib/observability/utils/spanSerializer.ts, src/lib/services/server/ai/observability/instrumentation.ts
SpanSerializer inherits active OTel trace/parent ids when present and maps WARNING; ContextEnricher sets Langfuse attributes and propagates WARNING/ERROR levels.
Tool-end emitter utility
src/lib/utils/toolEndEmitter.ts
New emitToolEndFromStepFinish(...) to normalize and emit tool:end events from provider step finishes.
Providers — tool events, streams, usage
src/lib/providers/... (anthropic.ts, openAI.ts, google*, huggingFace.ts, litellm.ts, mistral.ts, ollama.ts, amazonBedrock.ts, amazonSagemaker.ts, openRouter.ts, openaiCompatible.ts)
Integrates tool-end emission, aggregates stream usage, emits manual generation:end with finishReason/usage, and records stream/provider span error statuses.
Core runtime & handlers
src/lib/core/baseProvider.ts, src/lib/core/modules/GenerationHandler.ts, src/lib/core/modules/StreamHandler.ts, src/lib/core/modules/ToolsManager.ts, src/lib/neurolink.ts
Adjusts generation/stream fallbacks and gating, integrates tool-end emission, changes tool span-status handling, deduplicates Pipeline A/B generation emissions, and enriches generation/stream attributes.
Auth / token / session tracing
src/lib/auth/anthropicOAuth.ts, src/lib/auth/sessionManager.ts, src/lib/auth/tokenStore.ts
Wraps OAuth/session/token APIs with spans, moves core logic into private helpers, and records auth attributes (found/expired/refreshed).
HTTP clients / SSE / WS
src/lib/client/httpClient.ts, src/lib/client/sseClient.ts, src/lib/client/streamingClient.ts, src/lib/client/wsClient.ts
Adds client spans around requests/streams/connects, captures method/route/url and SSE/WS lifecycle attributes, delegates to private helpers.
Context, memory, RAG instrumentation
src/lib/context/*, src/lib/memory/memoryRetrievalTools.ts, src/lib/rag/*
Wraps compaction, budget checks, summarization, retrieval tools, chunkers, reranker, vector queries with spans and links to active trace context; records content/query/usage attributes.
MCP / discovery / batching / rate-limit
src/lib/mcp/*
Adds spans to discovery/registry/batching; propagates trace context to rate-limit/connect spans; enhances error/status handling and diagnostics for upstream 429/auth retries.
Workflow / ensemble / scoring / conditioning
src/lib/workflow/core/*
Wraps workflow orchestration, ensemble/group execution, judge scoring, and conditioning in spans; introduces inner helpers that accept OTel spans and record counts/timings.
Server routes & middleware
src/lib/server/routes/*, src/lib/server/middleware/common.ts
Introduces traced route handlers/middleware across agent, claude-proxy (429 diagnostics & retry budget change), health, MCP, memory, OpenAPI, tool routes, and timing middleware with OTel error recording.
Processors, CLI & file processing
src/lib/processors/..., src/lib/cli/loop/session.ts
Wraps file processor/registry and CLI per-turn execution in spans with processor/file/session attributes.
SpanSerializer callsites
various files (src/lib/*)
Many modules updated to propagate active trace context or use withSpan wrappers so created spans are linked to OTel active context.

Sequence Diagram(s)

sequenceDiagram
    participant Provider as Provider (e.g., OpenAI/Bedrock)
    participant Neurolink as Neurolink (event emitter)
    participant SpanUtil as SpanSerializer / withSpan
    participant Backend as Langfuse / Metrics

    Provider->>Neurolink: onStepFinish (toolResults)
    Neurolink->>SpanUtil: emitToolEndFromStepFinish -> create "tool:end" span (with traceId/parentSpanId)
    SpanUtil->>Backend: serialize & send span (status WARNING/ERROR/OK)
    Backend-->>SpanUtil: ack
    SpanUtil-->>Neurolink: span recorded / instrumentation complete
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related PRs

Suggested labels

released

Suggested reviewers

  • adarshba
  • pdogra1299
  • Pdogra2520

Poem

🐇 I hopped through spans and threads so neat,
I stitched traceIds where pipelines meet.
Tool ends chirp, generation tales unfurl,
Metrics hum softly across the world.
A tiny rabbit marked each span complete.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 73.91% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically summarizes the main changes: fixing telemetry gaps, adding proxy error logging, and increasing retry limits. It is concise and directly relevant to the changeset.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/telemetry-proxy-observability

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions

Copy link
Copy Markdown
Contributor

🤖 AI Review & Build Compliance ✅

Status: AI analysis complete • Build rules validated • Ready for review

📊 View detailed analysis results

🛡️ Analysis Complete

  • ✅ Security scan (vulnerabilities, API keys)
  • ✅ TypeScript safety & code quality
  • ✅ Error handling & best practices
  • ✅ Build rule enforcement validated
  • ✅ Commit format & compliance checks

📋 Ready for Merge When

  • All CI checks passing
  • Manual review approved
  • Any AI-flagged issues resolved

🤖 AI analysis complete - check individual code comments for specific feedback

@github-actions

github-actions Bot commented Apr 17, 2026 •

Copy link
Copy Markdown
Contributor

Documentation Validation Results

⚠️ Documentation validation has issues

Check Status Result
Frontmatter Validation ✅ Passed
TypeScript Check ✅ Passed
Build ❌ Failed
Link Validation ⏩ Skipped

🚧 Please fix the failing checks before merging.

Commit: 9510803034f065d81b3e69e0a86835547e61de0e | Workflow: View logs

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR substantially expands and aligns observability across NeuroLink by closing telemetry gaps between OTel/Langfuse (Pipeline A) and the internal MetricsAggregator/Langfuse API flow (Pipeline B), while also improving Claude proxy diagnostics (full upstream error capture) and increasing 429 retry limits.

Changes:

  • Add widespread withSpan/tracer instrumentation across workflow, HTTP routes, RAG, memory, MCP, auth, processors, evaluation, clients, and CLI turns.
  • Link Pipeline B spans to the active OTel context via getActiveTraceContext() and enrich Langfuse attributes/levels (ERROR/WARNING, usage details, model params).
  • Enhance Claude proxy error diagnostics (headers/body capture for non-200s incl. 429/auth-retry) and increase per-account 429 retries from 5 → 10.

Reviewed changes

Copilot reviewed 69 out of 69 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
src/lib/workflow/core/responseConditioner.ts Wrap response conditioning with workflow tracer spans and conditioning attributes.
src/lib/workflow/core/judgeScorer.ts Add workflow spans and judge timing attributes.
src/lib/workflow/core/ensembleExecutor.ts Add workflow spans + success/failure/time attributes for ensemble execution.
src/lib/utils/toolEndEmitter.ts New helper to emit tool:end from AI-SDK onStepFinish tool results.
src/lib/types/span.ts Add WARNING to SpanStatus.
src/lib/telemetry/tracers.ts Add auth and workflow tracers.
src/lib/telemetry/traceContext.ts New helper to read active OTel trace/parent span IDs.
src/lib/services/server/ai/observability/instrumentation.ts Enrich spans for Langfuse (usage_details, model params, WARNING/ERROR level/status message).
src/lib/server/routes/toolRoutes.ts Add HTTP spans for tool list/search/execute endpoints.
src/lib/server/routes/openApiRoutes.ts Add HTTP spans for OpenAPI JSON/YAML/docs endpoints.
src/lib/server/routes/memoryRoutes.ts Add helper wrapper for HTTP spans around memory routes.
src/lib/server/routes/mcpRoutes.ts Add helper wrapper for HTTP spans around MCP routes.
src/lib/server/routes/healthRoutes.ts Add helper wrapper + health attributes for HTTP spans around health/version endpoints.
src/lib/server/routes/claudeProxyRoutes.ts Increase 429 retry limit; add full upstream error capture for 429/auth-retry paths; add HTTP spans for proxy utility endpoints.
src/lib/server/routes/agentRoutes.ts Add HTTP spans around agent execute/stream/embed endpoints.
src/lib/server/middleware/common.ts Add OTel span + ERROR status propagation for timing middleware.
src/lib/rag/retrieval/vectorQueryTool.ts Add RAG spans and attributes for vector query tool execution.
src/lib/rag/reranker/reranker.ts Add RAG spans around reranking and batch reranking; emit output counts.
src/lib/rag/ragIntegration.ts Add RAG search span wrapping in-memory RAG tool execute.
src/lib/rag/chunkers/BaseChunker.ts Add RAG chunking span with strategy/content length/chunk count attributes.
src/lib/providers/openaiCompatible.ts Emit tool:end events from AI-SDK onStepFinish.
src/lib/providers/openRouter.ts Emit tool:end events from AI-SDK onStepFinish.
src/lib/providers/openAI.ts Emit tool:end events from onStepFinish; set OTel ERROR status/exception on stream failures.
src/lib/providers/ollama.ts Include usage in streaming; aggregate usage; emit generation:end; emit tool:end for tool results.
src/lib/providers/mistral.ts Emit tool:end events from AI-SDK onStepFinish.
src/lib/providers/litellm.ts Emit tool:end events from AI-SDK onStepFinish.
src/lib/providers/huggingFace.ts Emit tool:end events from AI-SDK onStepFinish.
src/lib/providers/googleVertex.ts Emit tool:end; emit generation:end for native paths that bypass AI SDK telemetry.
src/lib/providers/googleAiStudio.ts Emit tool:end; emit generation:end for native paths; set OTel ERROR status/exception on stream failures.
src/lib/providers/azureOpenai.ts Emit tool:end events from AI-SDK onStepFinish.
src/lib/providers/anthropic.ts Emit tool:end; set OTel ERROR status/exception on stream failures.
src/lib/providers/amazonSagemaker.ts Add span around stream() and mark not implemented via attributes.
src/lib/providers/amazonBedrock.ts Emit generation:end (generate + stream); aggregate usage; map AWS throttling to RateLimitError; emit tool:end for tool results.
src/lib/processors/registry/ProcessorRegistry.ts Add processor span for processFile dispatch.
src/lib/processors/base/BaseFileProcessor.ts Add file processing span with file metadata attributes.
src/lib/observability/utils/spanSerializer.ts Inherit active OTel trace context for Pipeline B spans; map WARNING to Langfuse level.
src/lib/neurolink.ts Multiple telemetry fixes: reuse active OTel context, avoid duplicate Pipeline B generation spans when Pipeline A handled, WARNING on finish reasons, stream events/errors, abort classification, tool retry count, MCP error extraction.
src/lib/memory/memoryRetrievalTools.ts Add memory retrieval span; improve span status handling for not found; record counts/sizes.
src/lib/mcp/toolRegistry.ts Rename span name to avoid duplication; explicitly set OTel ERROR when returning an error result (no throw).
src/lib/mcp/toolDiscoveryService.ts Add span around tool discovery and record discovered count.
src/lib/mcp/mcpClientFactory.ts Link MCP transport spans to active OTel trace context.
src/lib/mcp/httpRetryHandler.ts Link MCP retry spans to active OTel trace context.
src/lib/mcp/httpRateLimiter.ts Link MCP rate limiter spans to active OTel trace context.
src/lib/mcp/batching/requestBatcher.ts Add MCP batch execution span with batch metrics.
src/lib/evaluation/ragasEvaluator.ts Add evaluation span + expose scores on OTel span.
src/lib/core/modules/ToolsManager.ts Set OTel span status ERROR when tool result indicates isError:true.
src/lib/core/modules/StreamHandler.ts Emit sentinel chunk on NoOutputGeneratedError to enable WARNING classification downstream.
src/lib/core/modules/GenerationHandler.ts Emit tool:end for AI-SDK-driven tool calls via shared helper.
src/lib/context/summarizationEngine.ts Add memory summarization span + summarized/not attributes.
src/lib/context/contextCompactor.ts Add context compaction span; link Pipeline B span to active OTel; mark WARNING when compaction insufficient.
src/lib/context/budgetChecker.ts Link budget check span to active OTel trace context.
src/lib/client/wsClient.ts Track WebSocket connection lifecycle with an OTel span, ending on close/error/disconnect.
src/lib/client/streamingClient.ts Add client spans around SSE/WS connect calls.
src/lib/client/sseClient.ts Add client span around SSE stream initiation.
src/lib/client/httpClient.ts Add client span around HTTP requests.
src/lib/auth/tokenStore.ts Add auth spans around token save/load/clear/get-valid and include useful attributes.
src/lib/auth/sessionManager.ts Add auth spans around session create/get/refresh/validate with masked IDs.
src/lib/auth/anthropicOAuth.ts Add auth spans around OAuth exchange/refresh/revoke operations.
src/cli/loop/session.ts Add per-turn CLI span for tracing interactive loop commands.
docs/telemetry-fix-diff.txt Record before/after verification output diff.
docs/telemetry-fix-completion-report.md Summary report of resolved gaps and remaining known gap.
docs/telemetry-baseline-proof.txt Baseline proof output for gap verification suite.
docs/telemetry-after-proof.txt Post-fix proof output for gap verification suite.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/lib/server/routes/agentRoutes.ts Outdated
Comment on lines +139 to +189
return withSpan(
{
name: "neurolink.http.stream",
tracer: tracers.http,
attributes: {
"http.route": "/api/agent/stream",
"ai.provider": request.provider || "default",
"ai.model": request.model || "default",
},
},
});
async () => {
// Normalize input
const input =
typeof request.input === "string"
? { text: request.input }
: request.input;

// Create redactor (no-op if redaction is not enabled)
const redactor = createStreamRedactor(ctx.redaction);
const result = await ctx.neurolink.stream({
input,
provider: request.provider,
model: request.model,
systemPrompt: request.systemPrompt,
temperature: request.temperature,
maxTokens: request.maxTokens,
context: {
// When an authenticated user context exists (set by auth middleware),
// always use its IDs to prevent caller-supplied impersonation.
sessionId: ctx.user
? ctx.session?.id
: (ctx.session?.id ?? request.sessionId),
userId: ctx.user ? ctx.user.id : request.userId,
userEmail: ctx.user?.email,
userRoles: ctx.user?.roles,
requestId: ctx.requestId,
},
});

// Wrap stream to apply redaction to each chunk
async function* redactedStream(): AsyncIterable<unknown> {
for await (const chunk of result.stream) {
// Apply redaction to chunk (returns unchanged if redaction disabled)
yield redactor(chunk);
}
}
// Create redactor (no-op if redaction is not enabled)
const redactor = createStreamRedactor(ctx.redaction);

return redactedStream();
// Wrap stream to apply redaction to each chunk
async function* redactedStream(): AsyncIterable<unknown> {
for await (const chunk of result.stream) {
// Apply redaction to chunk (returns unchanged if redaction disabled)
yield redactor(chunk);
}
}

return redactedStream();
},
); // end withSpan

Copilot AI Apr 17, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This streaming handler returns an AsyncIterable from inside withSpan. Since withSpan ends the span as soon as the callback resolves, the span will end immediately after the generator is created (before any chunks are sent, and before stream errors can be observed). To accurately trace streaming duration/outcome, wrap the returned async iterator so the span is ended in a finally after iteration completes (or use a streaming-aware span helper).

Copilot uses AI. Check for mistakes.
Comment on lines +92 to +178
try {
// Prevent concurrent discovery for same server
if (this.discoveryInProgress.has(serverId)) {
return {
success: false,
error: `Discovery already in progress for server: ${serverId}`,
toolCount: 0,
tools: [],
duration: Date.now() - startTime,
serverId,
};
}

this.discoveryInProgress.add(serverId);

mcpLogger.info(
`[ToolDiscoveryService] Starting tool discovery for server: ${serverId}`,
);

// Create circuit breaker for tool discovery
const circuitBreaker = globalCircuitBreakerManager.getBreaker(
`tool-discovery-${serverId}`,
{
failureThreshold: 2,
resetTimeout: 60000,
operationTimeout: timeout,
},
);
// Create circuit breaker for tool discovery
const circuitBreaker = globalCircuitBreakerManager.getBreaker(
`tool-discovery-${serverId}`,
{
failureThreshold: 2,
resetTimeout: 60000,
operationTimeout: timeout,
},
);

// Discover tools with circuit breaker protection
const tools = await circuitBreaker.execute(async () => {
return await this.performToolDiscovery(serverId, client, timeout);
});
// Discover tools with circuit breaker protection
const tools = await circuitBreaker.execute(async () => {
return await this.performToolDiscovery(serverId, client, timeout);
});

// Register discovered tools
const registeredTools = await this.registerDiscoveredTools(
serverId,
tools,
);
// Register discovered tools
const registeredTools = await this.registerDiscoveredTools(
serverId,
tools,
);

const result: ToolDiscoveryResult = {
success: true,
toolCount: registeredTools.length,
tools: registeredTools,
duration: Date.now() - startTime,
serverId,
};
span.setAttribute("mcp.tools_discovered", registeredTools.length);

// Emit discovery completed event
this.emit("discoveryCompleted", {
serverId,
toolCount: registeredTools.length,
duration: result.duration,
timestamp: new Date(),
} satisfies ToolRegistryEvents["discoveryCompleted"]);
const result: ToolDiscoveryResult = {
success: true,
toolCount: registeredTools.length,
tools: registeredTools,
duration: Date.now() - startTime,
serverId,
};

mcpLogger.info(
`[ToolDiscoveryService] Discovery completed for ${serverId}: ${registeredTools.length} tools`,
);
// Emit discovery completed event
this.emit("discoveryCompleted", {
serverId,
toolCount: registeredTools.length,
duration: result.duration,
timestamp: new Date(),
} satisfies ToolRegistryEvents["discoveryCompleted"]);

return result;
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
mcpLogger.info(
`[ToolDiscoveryService] Discovery completed for ${serverId}: ${registeredTools.length} tools`,
);

mcpLogger.error(
`[ToolDiscoveryService] Discovery failed for ${serverId}:`,
error,
);
return result;
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);

// Emit discovery failed event
this.emit("discoveryFailed", {
serverId,
error: errorMessage,
timestamp: new Date(),
} satisfies ToolRegistryEvents["discoveryFailed"]);
mcpLogger.error(
`[ToolDiscoveryService] Discovery failed for ${serverId}:`,
error,
);

return {
success: false,
error: errorMessage,
toolCount: 0,
tools: [],
duration: Date.now() - startTime,
serverId,
};
} finally {
this.discoveryInProgress.delete(serverId);
}
// Emit discovery failed event
this.emit("discoveryFailed", {
serverId,
error: errorMessage,
timestamp: new Date(),
} satisfies ToolRegistryEvents["discoveryFailed"]);

return {
success: false,
error: errorMessage,
toolCount: 0,
tools: [],
duration: Date.now() - startTime,
serverId,
};

Copilot AI Apr 17, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

discoverTools() catches errors and returns {success:false,...} (and also returns early when discovery is already in progress). Because withSpan marks the span OK unless an exception is thrown, these failure cases will be recorded as OK spans. Please set the span status to ERROR (and record the exception / error message) before returning success:false so MCP discovery failures are visible in traces.

Copilot uses AI. Check for mistakes.
Comment on lines +154 to +193
try {
// Get tool from registry
const tools = await ctx.toolRegistry.listTools();
const tool = tools.find((t) => t.name === request.name);

// Execute the tool
const result = await ctx.toolRegistry.executeTool(
request.name,
request.arguments,
);
if (!tool) {
span.setAttribute("tool.found", false);
return {
success: false,
error: `Tool '${request.name}' not found`,
duration: Date.now() - startTime,
};
}

return {
success: true,
data: result,
duration: Date.now() - startTime,
metadata: {
toolName: request.name,
sessionId: request.sessionId || null,
},
};
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
return {
success: false,
error: errorMessage,
duration: Date.now() - startTime,
};
}
// Execute the tool
const result = await ctx.toolRegistry.executeTool(
request.name,
request.arguments,
);

span.setAttribute("tool.success", true);
return {
success: true,
data: result,
duration: Date.now() - startTime,
metadata: {
toolName: request.name,
sessionId: request.sessionId || null,
},
};
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
span.setAttribute("tool.success", false);
return {
success: false,
error: errorMessage,
duration: Date.now() - startTime,
};
}

Copilot AI Apr 17, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

withSpan always sets the span status to OK unless the callback throws. In this handler you return error payloads (e.g., tool not found / execution failure) instead of throwing, so the OTel span will still be marked OK and won’t reflect the failure. Consider setting span status to ERROR (and recording the exception) before returning success:false, including the tool-not-found branch.

Copilot uses AI. Check for mistakes.
Comment on lines +247 to +282
try {
// Get tool from registry
const tools = await ctx.toolRegistry.listTools();
const tool = tools.find((t) => t.name === name);

// Execute the tool
const result = await ctx.toolRegistry.executeTool(name, args);
if (!tool) {
span.setAttribute("tool.found", false);
return {
success: false,
error: `Tool '${name}' not found`,
duration: Date.now() - startTime,
};
}

return {
success: true,
data: result,
duration: Date.now() - startTime,
metadata: {
toolName: name,
},
};
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
return {
success: false,
error: errorMessage,
duration: Date.now() - startTime,
};
}
// Execute the tool
const result = await ctx.toolRegistry.executeTool(name, args);

span.setAttribute("tool.success", true);
return {
success: true,
data: result,
duration: Date.now() - startTime,
metadata: {
toolName: name,
},
};
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
span.setAttribute("tool.success", false);
return {
success: false,
error: errorMessage,
duration: Date.now() - startTime,
};
}

Copilot AI Apr 17, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue as the /tools/execute route: errors are converted into {success:false} return values, so withSpan will still set the span status to OK. Please set span status to ERROR (and optionally recordException) before returning error responses like tool-not-found and execution errors so traces accurately reflect failures.

Copilot uses AI. Check for mistakes.
Comment on lines +57 to +65
return withSpan(
{
name: "neurolink.http.execute",
tracer: tracers.http,
attributes: {
"http.route": "/api/agent/execute",
"ai.provider": request.provider || "default",
"ai.model": request.model || "default",
},

Copilot AI Apr 17, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The http.route attribute is hard-coded to /api/..., but this router supports a configurable basePath. If createAgentRoutes() is used with a non-default basePath, traces will be attributed to the wrong route. Use the ${basePath}/agent/... path string (or the same path value used in the route definition) for http.route here (and in the other agent handlers in this file).

Copilot uses AI. Check for mistakes.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 14

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (6)
src/lib/auth/tokenStore.ts (1)

420-431: ⚠️ Potential issue | 🟡 Minor

auth.refreshed attribute not set on the no-refresher fallback path.

When no refresher/refresh token is available, the method returns at line 430 without setting auth.refreshed. Downstream queries filtering on auth.refreshed=false to count "served expired-but-still-valid-under-strict-check" tokens will miss this path. Consider span.setAttribute("auth.refreshed", false) before the return to keep the attribute present on every get_valid span outcome.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/auth/tokenStore.ts` around lines 420 - 431, In the Phase 2 refresher
branch inside get_valid (where tokenRefreshers.get(provider) and
snapshot.refreshToken are checked), add span.setAttribute("auth.refreshed",
false) before the early return so the no-refresher fallback path records
auth.refreshed; keep the existing logger and then call
span.setAttribute("auth.refreshed", false) immediately prior to returning the
result of isTokenExpired(snapshot, 0) ? null : snapshot.accessToken.
src/lib/workflow/core/ensembleExecutor.ts (1)

140-158: ⚠️ Potential issue | 🟡 Minor

Outer OTel span is not marked ERROR when the ensemble fails without throwing.

When successCount === 0, the inner SpanSerializer span is ended with SpanStatus.ERROR, but the withSpan-wrapped OTel span (the one emitted to the OTel pipeline) returns normally and will record OK/UNSET status — so Langfuse/OTel sees a "successful" ensemble span while the internal metrics aggregator sees a failure. Consider calling otelSpan.setStatus({ code: SpanStatusCode.ERROR, message: "No successful model responses" }) on the zero-success path to keep the two pipelines consistent.

The same applies to executeModelGroupsInner at lines 499–501 when totalSuccessCount === 0.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/workflow/core/ensembleExecutor.ts` around lines 140 - 158, The outer
OpenTelemetry span (otelSpan) isn't marked ERROR when all model responses fail,
causing a mismatch between the internal SpanSerializer status and OTel; update
ensembleExecutor.ts to set the OTel span status when successCount === 0 by
calling otelSpan.setStatus({ code: SpanStatusCode.ERROR, message: "No successful
model responses" }) right after computing spanStatus (and before returning), and
apply the same change in executeModelGroupsInner where totalSuccessCount === 0
(use the same otelSpan.setStatus call with a similar message) so the OTel span
and the internal metrics aggregator remain consistent; reference otelSpan,
SpanStatusCode, successCount, and totalSuccessCount to locate the fixes.
src/lib/client/wsClient.ts (2)

355-370: ⚠️ Potential issue | 🟡 Minor

onerror without onclose can leak the span.

The comment says "onclose will fire next and end the span," which is true for transport-level errors in browser/ws. However, there are paths where onerror fires but the socket is discarded before onclose runs (e.g., the process/tab exits, or the socket is GC'd after the caller drops the reference). In those cases the span stays open forever.

A safer pattern is to record the error here, set ERROR status, and leave a timer/flag so that if onclose doesn't arrive within a small window the span is force-ended. Alternatively, just end the span here and let onclose no-op if connectionSpan === null. The latter is simpler and reliable.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/client/wsClient.ts` around lines 355 - 370, The onerror handler can
leak connectionSpan if onclose never fires; modify the onerror block in
wsClient.ts (the this.ws.onerror handler) to not only recordException and
setStatus on connectionSpan but also call connectionSpan.end() and then clear
connectionSpan (set it to null) so the span is finalized immediately; also
ensure the this.ws.onclose handler (the onclose logic) is already guarded to
no-op when connectionSpan is null (or add that guard) so calling end() here
won’t double-end the span.

129-192: ⚠️ Potential issue | 🟠 Major

Span leak on exceptions and re-entrant connect().

Two leak paths were introduced here:

  1. The span is started at line 143 before new URL(this.config.baseUrl) (line 157) and the WebSocket constructor (lines 182–187). If either throws (invalid URL, ws package not installed, browser CSP blocking WS), connectionSpan is populated but never ended — the exception propagates out of connect() without any span.end().
  2. The early return at lines 130–132 only guards the fully OPEN case. If connect() is called while a previous attempt is still in connecting, error, or closing state, this code overwrites this.connectionSpan with a fresh one, orphaning the previous span (it'll only be ended if the previous ws eventually reaches onclose/onerror, which is not guaranteed if the app is throwing away the client).

Wrap the constructor calls in try/catch and also end any pre-existing span before starting a new one:

🔒 Suggested fix
-    this.connectionSpan = tracers.http.startSpan(
-      "neurolink.client.ws.connect",
-      {
-        kind: SpanKind.CLIENT,
-        attributes: {
-          "http.url": this.config.baseUrl,
-          "ws.auto_reconnect": this.config.autoReconnect,
-          "ws.reconnect_attempt": this.reconnectAttempts,
-        },
-      },
-    );
+    // End any stale span from a prior failed/interrupted connect attempt.
+    if (this.connectionSpan) {
+      this.connectionSpan.setStatus({
+        code: SpanStatusCode.ERROR,
+        message: "Superseded by new connect() call",
+      });
+      this.connectionSpan.end();
+      this.connectionSpan = null;
+    }
+    this.connectionSpan = tracers.http.startSpan(
+      "neurolink.client.ws.connect",
+      {
+        kind: SpanKind.CLIENT,
+        attributes: {
+          "http.url": this.config.baseUrl,
+          "ws.auto_reconnect": this.config.autoReconnect,
+          "ws.reconnect_attempt": this.reconnectAttempts,
+        },
+      },
+    );
+
+    try {
+      // ...URL + WebSocket construction...
+    } catch (error) {
+      const err = error instanceof Error ? error : new Error(String(error));
+      this.connectionSpan.recordException(err);
+      this.connectionSpan.setStatus({
+        code: SpanStatusCode.ERROR,
+        message: err.message,
+      });
+      this.connectionSpan.end();
+      this.connectionSpan = null;
+      throw error;
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/client/wsClient.ts` around lines 129 - 192, connect() can leak
connectionSpan on exceptions and when connect() is re-entered; before creating a
new span ensure any existing this.connectionSpan is ended (call
this.connectionSpan.end()) and avoid stomping an in-progress connect by
returning early if state is "connecting" (or other non-OPEN transient states),
then wrap the URL construction and WebSocket instantiation (the new URL(...) and
new WebSocket(...) / WebSocket as ws constructor block that assigns this.ws) in
a try/catch so that if either throws you call this.connectionSpan.end() (and
clear this.connectionSpan) before rethrowing the error; ensure
setupEventListeners() and this.pendingAuth are only set after successful socket
creation so the span lifecycle is correctly terminated in onclose/onerror
handlers.
src/lib/neurolink.ts (2)

3899-3912: ⚠️ Potential issue | 🟠 Major

Only set pipelineAHandled when Pipeline A actually emitted a generation observation.

finalizeGenerateRequestResult() runs for every successful generate() path, so this unconditionally disables Pipeline B in initializeMetricsListeners(). Native-provider executions that do not create Pipeline A generation spans will now lose their only generation observation entirely.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/neurolink.ts` around lines 3899 - 3912,
finalizeGenerateRequestResult() is unconditionally setting pipelineAHandled
which prevents Pipeline B spans even when Pipeline A didn't emit one; change it
to set pipelineAHandled only when there is a definite signal that Pipeline A
emitted a generation observation (e.g., check a marker on the result like
textResult.pipelineAGenerationEmitted or a metadata flag added by the Pipeline A
emission code), and ensure Pipeline A's emission path sets that marker when it
emits; update initializeMetricsListeners() to rely on that marker instead of
assuming true and only skip creating the Pipeline B span when the marker is
present.

9004-9035: ⚠️ Potential issue | 🟡 Minor

Failed tool spans still miss tool.retry_count.

The attribute is only set after withRetry() returns successfully. If the last retry still fails and control goes to catch, the span is ended without the retry count, which hides the retry history on the failure cases you most need to inspect.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/neurolink.ts` around lines 9004 - 9035, The tool retry count is only
recorded after withRetry() succeeds, so failed executions never get
tool.retry_count; update the control flow around prepared.circuitBreaker.execute
/ withRetry so toolSpan.setAttribute("tool.retry_count", toolRetryCount) is
always called even on failure — e.g., ensure the attribute is set in a finally
block that wraps the execute() call (or set it inside the onRetry handler and
also in the catch before rethrow) so that toolRetryCount (tracked by the onRetry
callback) is written to the span regardless of success or error.
🟡 Minor comments (10)
src/cli/loop/session.ts-171-199 (1)

171-199: ⚠️ Potential issue | 🟡 Minor

Set ERROR status alongside recordException for per-turn spans.

recordException adds an exception event but does not change the span's status; the span will still be exported with the default UNSET/OK status, which is inconsistent with the explicit setStatus({ code: ERROR }) pattern used throughout this PR (e.g., toolRegistry.ts T3 fix, StreamHandler.validateStreamOptions). This undercuts the Pipeline A Langfuse level/status enrichment the PR claims to close.

Also consider using withSpan for consistency — it handles status/exception/end uniformly and matches the rest of the PR.

🛠️ Minimal fix
             } catch (e) {
-              turnSpan.recordException(
-                e instanceof Error ? e : new Error(String(e)),
-              );
+              const err = e instanceof Error ? e : new Error(String(e));
+              turnSpan.recordException(err);
+              turnSpan.setStatus({
+                code: SpanStatusCode.ERROR,
+                message: err.message,
+              });
               throw e;
             } finally {
               turnSpan.end();
             }

Add import:

-import { tracers } from "../../lib/telemetry/tracers.js";
+import { tracers } from "../../lib/telemetry/tracers.js";
+import { SpanStatusCode } from "@opentelemetry/api";
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/cli/loop/session.ts` around lines 171 - 199, The span for each CLI turn
currently only calls turnSpan.recordException but doesn't mark the span status
as ERROR; update the error handling inside tracers.sdk.startActiveSpan (the
async callback that receives turnSpan) to call turnSpan.setStatus({ code:
tracers.sdk.SpanStatusCode.ERROR }) (or the equivalent enum/value exported by
tracers.sdk) whenever an exception is recorded, or replace the startActiveSpan
usage with tracers.sdk.withSpan/withActiveSpan helper so status, exception and
end are handled uniformly; ensure you reference the existing
turnSpan.recordException call and add the corresponding turnSpan.setStatus call
in the catch block to match the pattern used in toolRegistry.ts and
StreamHandler.validateStreamOptions.
src/lib/utils/toolEndEmitter.ts-54-80 (1)

54-80: ⚠️ Potential issue | 🟡 Minor

Edge case: array-typed output will pass the object check.

typeof output === "object" is true for arrays, so if an SDK version returns output as a raw content array (rather than { content: [...] }), the "isError" in output check runs against array indices/properties and the subsequent output.content access yields undefined, falling through to the generic "Tool returned isError: true" message. Minor, but consider an explicit !Array.isArray(output) guard if you want the array-as-output shape to be handled (extract text parts directly from it).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/utils/toolEndEmitter.ts` around lines 54 - 80, The object-type checks
treat arrays as objects, causing array-typed output to be misinterpreted; update
the isError detection and handling in the loop over toolResults (variables tr,
output, isError) to explicitly exclude arrays (e.g., require
!Array.isArray(output) when checking "isError" in output) and add handling for
when output itself is an array (extract text parts from the array entries
similar to the content-array branch) so that array-shaped outputs produce useful
error messages instead of falling back to "Tool returned isError: true".
src/lib/client/httpClient.ts-256-268 (1)

256-268: ⚠️ Potential issue | 🟡 Minor

Span omits response status and error details — reduces observability value.

The span records http.method/http.route/http.url on entry but never records http.response.status_code, retry counts, or exceptions (HttpError, ClientTimeoutError, ClientNetworkError). For a telemetry-focused PR, this is a notable gap: failed HTTP calls (4xx/5xx/timeouts) will appear as successful spans. Consider capturing the outcome in the callback:

♻️ Suggested enrichment
-      async () => this._doRequest<T>(method, path, body, options),
+      async (span) => {
+        try {
+          const res = await this._doRequest<T>(method, path, body, options);
+          span.setAttribute("http.response.status_code", res.status);
+          if (context && typeof context === "object") {
+            // optionally attach retryCount from context if exposed
+          }
+          return res;
+        } catch (err) {
+          if (err instanceof HttpError) {
+            span.setAttribute("http.response.status_code", err.status);
+          }
+          if (err instanceof Error) {
+            span.recordException(err);
+          }
+          span.setStatus({
+            code: SpanStatusCode.ERROR,
+            message: err instanceof Error ? err.message : String(err),
+          });
+          throw err;
+        }
+      },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/client/httpClient.ts` around lines 256 - 268, The span created in
withClientSpan (name "neurolink.client.http.request", tracer tracers.http)
currently only records request attributes; update the callback around
this._doRequest<T>(...) to capture and set additional span attributes and
status: after the request completes, add "http.response.status_code" and retry
count (if available) to the span and mark span status based on status code; on
thrown errors (HttpError, ClientTimeoutError, ClientNetworkError or generic
exceptions) record the exception details and error attributes on the span and
set an error status; ensure you use the same span object returned by
withClientSpan to call setAttribute/setStatus/recordException so
failures/timeouts are reflected in telemetry for _doRequest and related retry
logic.
docs/telemetry-after-proof.txt-98-103 (1)

98-103: ⚠️ Potential issue | 🟡 Minor

The typecheck evidence is internally inconsistent.

This section includes an ERR_MODULE_NOT_FOUND stack trace and then immediately reports 0 ERRORS. Please regenerate or annotate the artifact so the actual typecheck outcome is unambiguous.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/telemetry-after-proof.txt` around lines 98 - 103, The TYPECHECK section
is inconsistent: it shows an ERR_MODULE_NOT_FOUND stack trace (the node internal
ModuleJob syncLink error) but the summary line reports "0 ERRORS"; regenerate
the artifact or edit this section so the actual typecheck result is unambiguous
by either (a) re-running the typecheck and replacing the block so the stack
trace and the final summary match, or (b) annotating the section to explicitly
state that the stack trace is a transient module resolution warning and that the
final "COMPLETED ... 0 ERRORS" is the authoritative result; ensure you address
the stack trace containing 'ERR_MODULE_NOT_FOUND' and the summary line that
currently reports zero errors so readers can see a consistent outcome.
docs/telemetry-gaps-audit.md-460-462 (1)

460-462: ⚠️ Potential issue | 🟡 Minor

The verification-suite path is stale.

This points to test/telemetry-gaps-verification.test.ts, but the PR adds test/telemetry-gaps-verification.ts. The current reference sends readers to the wrong file.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/telemetry-gaps-audit.md` around lines 460 - 462, Update the "8.
Verification Plan" reference in docs/telemetry-gaps-audit.md so it points to the
new test file name `test/telemetry-gaps-verification.ts` (not the stale
`test/telemetry-gaps-verification.test.ts`); edit the sentence that currently
mentions `test/telemetry-gaps-verification.test.ts` to use the correct
backticked path `test/telemetry-gaps-verification.ts` so readers are directed to
the actual verification suite added in the PR.
src/lib/rag/chunkers/BaseChunker.ts-101-107 (1)

101-107: ⚠️ Potential issue | 🟡 Minor

Empty-content validation is recorded as a span exception.

Because the content.trim().length === 0 check lives inside withSpan(...), throwing ChunkingError here will cause withSpan to call span.recordException(...) and setStatus({ code: ERROR }). Empty input is an input-validation failure, not a chunker runtime error — marking the span ERROR will pollute error metrics/alerts in Langfuse with caller-side mistakes.

Consider validating before entering the span (or setting a distinct attribute and still returning []/OK for empty input), mirroring how other "invalid input" paths are typically handled.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/rag/chunkers/BaseChunker.ts` around lines 101 - 107, The
empty-content validation currently throws ChunkingError inside the withSpan
block causing withSpan to record an exception; move the content.trim().length
=== 0 check out of the withSpan(...) scope in the BaseChunker (the method around
the shown diff) and handle it as a caller/input validation: either return an
empty array immediately or throw the ChunkingError before entering withSpan so
the span is not marked ERROR; ensure references to this.strategy and
RAGErrorCodes.CHUNKING_EMPTY_CONTENT are preserved in the returned/raised path
so callers still get the same semantics.
src/lib/core/modules/GenerationHandler.ts-226-234 (1)

226-234: ⚠️ Potential issue | 🟡 Minor

Ad‑hoc cast can silently drop tool errors.

toolResults is cast to Array<{ toolName; output?; result?; error? }> without any runtime shape check. If the AI SDK changes field names (e.g. uses toolResult.error.message or nests under output), emitToolEndFromStepFinish will receive undefined for error/output and every Pipeline B tool span will appear successful — exactly the S2/G5 gap this PR is trying to close.

Consider defensively normalizing inside emitToolEndFromStepFinish (or here) rather than relying on a bare cast. At minimum, add a test that feeds a realistic AI‑SDK v5 tool‑error step result and asserts the resulting tool:end event carries the error.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/core/modules/GenerationHandler.ts` around lines 226 - 234, The code
unsafely casts toolResults before calling emitToolEndFromStepFinish, which can
drop nested SDK v5 error shapes; update GenerationHandler to defensively
normalize each entry in toolResults (use the symbol toolResults and call site
emitToolEndFromStepFinish/getEmitterFn) into the exact shape expected by
emitToolEndFromStepFinish by extracting error message from common variants (e.g.
result.error, error.message, output.error, output?.result?.error) and mapping
output/result into the expected fields, then pass the normalized array; also add
a unit test that constructs a realistic AI‑SDK v5 step result (with nested
error.message/output.*) and asserts the emitted tool:end event contains the
error.
docs/telemetry-fix-diff.txt-1-100 (1)

1-100: ⚠️ Potential issue | 🟡 Minor

No code — verification diff snapshot.

Generated artifact; no behavior implications. Separately, docs/telemetry-baseline-proof.txt shows the lint run failed with ELIFECYCLE exit 1 due to Prettier formatting warnings on docs/telemetry-fix-execution-plan.md, docs/telemetry-fix-plan.md, docs/telemetry-gaps-audit.md, and test/telemetry-gaps-verification.ts. Please run prettier --write on those files so CI can go green.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/telemetry-fix-diff.txt` around lines 1 - 100, The diff shows CI failing
due to Prettier formatting warnings; run prettier --write on the four affected
docs/test files (docs/telemetry-fix-execution-plan.md,
docs/telemetry-fix-plan.md, docs/telemetry-gaps-audit.md,
test/telemetry-gaps-verification.ts), re-stage the updated files, and
commit/push the changes so the lint step passes and CI can succeed.
test/telemetry-gaps-verification.ts-1001-1013 (1)

1001-1013: ⚠️ Potential issue | 🟡 Minor

T8's fallback anchor never updates executeIdx.

When execute: is absent but execute( exists, executeIdx stays -1, so the later substring(executeIdx, ...) starts from the top of the file. That can turn the RAG gap check into a false PASS/FAIL depending on unrelated content earlier in the file.

🛠️ Suggested fix
-      const executeIdx = source.indexOf("execute:");
+      let executeIdx = source.indexOf("execute:");
       if (executeIdx === -1) {
         // Try alternate patterns
-        const altIdx =
-          source.indexOf("execute(") || source.indexOf("execute =");
+        const altIdx = Math.max(
+          source.indexOf("execute("),
+          source.indexOf("execute ="),
+        );
         if (altIdx === -1) {
           return null;
         }
+        executeIdx = altIdx;
       }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/telemetry-gaps-verification.ts` around lines 1001 - 1013, The fallback
search for other "execute" anchors computes altIdx but never assigns it back to
executeIdx, so executeIdx remains -1 and executeSection/substring uses the wrong
start; fix by performing an explicit search for alternative anchors (e.g.,
search for "execute(" and "execute =" individually, pick the first index that is
>= 0) and assign that value to executeIdx (update the code around the current
source.indexOf("execute:") / altIdx logic), ensuring executeIdx is the correct
start before using executeSection and substring.
src/lib/neurolink.ts-3983-3991 (1)

3983-3991: ⚠️ Potential issue | 🟡 Minor

step_count will always be 1 here.

generateResult is assembled a few lines above and never includes a steps array, so these attributes can't reflect the actual multi-step/tool loop. Read the count from the pre-normalized internal result before the step data is dropped, or plumb the field through first.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/neurolink.ts` around lines 3983 - 3991, generateSpan's
neurolink.step_count and neurolink.max_steps_reached are computed from
generateResult which was normalized and no longer contains the original steps,
so step_count is always 1; instead locate the pre-normalized internal result
that contains the steps array (the variable/structure built a few lines above
where generateResult is assembled—the pre-normalized result used to construct
generateResult) and read its steps.length there (or pass the steps count through
into generateResult when normalizing). Update the code that sets generateSpan
attributes (currently using generateResult and options.maxSteps) to use that
pre-normalized steps count (or the newly plumbed field) so neurolink.step_count
reflects the actual tool/step loop and neurolink.max_steps_reached compares that
count to options.maxSteps.
🧹 Nitpick comments (10)
src/lib/providers/amazonSagemaker.ts (1)

126-153: Minor: sagemaker.not_implemented: true is fine, but the inner try/catch around a single throw is redundant.

handleProviderError is invoked unconditionally on the only throw inside the callback, so the try/catch just forwards. Since withSpan already records exceptions and sets ERROR status from thrown errors, you can simplify:

♻️ Simplification
       async () => {
-        try {
-          // For now, throw an error indicating this is not yet implemented
-          throw new SageMakerError(
-            "SageMaker streaming not yet fully implemented. Coming in next phase.",
-            {
-              code: "MODEL_ERROR",
-              statusCode: 501,
-              endpoint: this.modelConfig.endpointName,
-            },
-          );
-        } catch (error) {
-          throw this.handleProviderError(error);
-        }
+        throw this.handleProviderError(
+          new SageMakerError(
+            "SageMaker streaming not yet fully implemented. Coming in next phase.",
+            {
+              code: "MODEL_ERROR",
+              statusCode: 501,
+              endpoint: this.modelConfig.endpointName,
+            },
+          ),
+        );
       },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/providers/amazonSagemaker.ts` around lines 126 - 153, The async
callback passed to withSpan contains a redundant try/catch that only rethrows
via handleProviderError; remove the try/catch and directly throw the
SageMakerError from inside the callback so withSpan can record the exception and
set ERROR status; keep the span attributes (including
"sagemaker.not_implemented": true) and ensure you no longer call
handleProviderError from this callback (handleProviderError can remain used
elsewhere).
src/lib/mcp/batching/requestBatcher.ts (1)

287-415: Terminal .catch(...) changes error semantics of executeBatch() — it now silently swallows batch-level failures.

Previously executeBatch() would reject, letting the callers at lines 244, 267, and 421 log via their own .catch. With the new .catch((error) => logger.error(...)) at line 413, executeBatch() always resolves, so:

  • The caller .catch handlers at lines 244/267/421 become dead code.
  • drain() only polls isIdle so this is benign for it, but any future caller expecting rejection will be surprised.

Individual request reject() calls happen before the rethrow, so per-request contracts are preserved — this is purely about observability/API shape. Recommend either removing the terminal .catch (rely on caller-side handling) or removing the redundant caller-side handlers.

♻️ Option: drop the terminal catch, keep caller handlers
-    ).catch((error) => {
-      logger.error("Batch span execution failed:", error);
-    });
+    );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/mcp/batching/requestBatcher.ts` around lines 287 - 415, The terminal
.catch on the withSpan promise swallows batch-level errors and prevents
executeBatch from rejecting; remove the final .catch((error) =>
logger.error(...)) so the promise returned by the withSpan call (inside
executeBatch) can propagate rejections to callers (preserving the existing
caller-side .catch handlers at executeBatch call sites), or alternatively
replace it with logging followed by rethrow if you want logging here; locate the
withSpan invocation in executeBatch and delete the trailing .catch handler (or
change it to rethrow after logging) so batch-level failures are not silently
swallowed.
src/lib/providers/litellm.ts (1)

310-320: Tool-end emission looks correct, but note the inline type-cast is duplicated across providers.

The exact Array<{ toolName; output?; result?; error? }> cast is repeated in litellm, mistral, azureOpenai, huggingFace, and anthropic. Consider having emitToolEndFromStepFinish accept unknown[] (or an exported ToolStepResult[] alias from toolEndEmitter.ts) and perform the narrowing internally, so callers don't each maintain a parallel structural shape. Pure refactor — no behavior change.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/providers/litellm.ts` around lines 310 - 320, The repeated inline
cast of tool result shape should be removed by changing
emitToolEndFromStepFinish to accept a broader input (e.g., unknown[] or an
exported ToolStepResult[] type from toolEndEmitter.ts) and perform the
type-narrowing inside that function; export a ToolStepResult type alias from
toolEndEmitter.ts, update emitToolEndFromStepFinish signature to use that alias
or unknown[], and then remove the Array<{ toolName: string; output?: unknown;
result?: unknown; error?: string; }> casts at each call site (e.g., in litellm’s
onStepFinish where emitToolEndFromStepFinish(this.neurolink?.getEventEmitter(),
toolResults) is called); update other providers (mistral, azureOpenai,
huggingFace, anthropic) to pass the raw toolResults and rely on the centralized
narrowing inside emitToolEndFromStepFinish.
src/lib/server/routes/mcpRoutes.ts (1)

22-42: Consider consolidating duplicated tracedXxxHandler wrappers.

Per the AI summary, the same wrapper pattern (http.route + http.request.id + tracers.http) is now duplicated across mcpRoutes, memoryRoutes, healthRoutes, openApiRoutes, toolRoutes, and agentRoutes. Extracting a single tracedRouteHandler helper into a shared module (e.g. server/utils/tracedHandler.ts) would remove copy/paste and keep span attribute conventions centralized.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/server/routes/mcpRoutes.ts` around lines 22 - 42, Multiple route
wrappers (e.g., tracedMcpHandler) repeat the same span setup; create a single
reusable helper (e.g., tracedRouteHandler) in a shared module
(server/utils/tracedHandler.ts) that accepts name, route, and the handler fn and
internally calls withSpan using tracers.http and the attributes {"http.route":
route, "http.request.id": ctx.requestId}; then update tracedMcpHandler,
memoryRoutes, healthRoutes, openApiRoutes, toolRoutes, and agentRoutes to
delegate to this new tracedRouteHandler (preserve the original function
signatures like tracedMcpHandler and calls to withSpan/tracers.http so callers
don’t change).
src/lib/processors/registry/ProcessorRegistry.ts (1)

457-475: Consider recording outcome attributes on the span.

The span is created with input attributes only; consider adding processor.matched (boolean) / processor.name (from match.name) and recording exceptions via span.recordException on failure paths to make the trace useful for debugging unsupported-type/processing-failure scenarios. Otherwise looks clean.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/processors/registry/ProcessorRegistry.ts` around lines 457 - 475, The
span created by withSpan in ProcessorRegistry.processFile should record outcome
attributes and exceptions: after calling this.findProcessor(fileInfo.mimetype,
fileInfo.name) set span attributes "processor.matched" (true/false) and if
matched set "processor.name" to match.name; when no match is found keep
matched=false and return null; wrap the processor.processFile(fileInfo, options)
call in try/catch and on error call span.recordException(error) and set an
attribute like "processor.error" or "processor.status" before rethrowing or
returning a failure; reference the withSpan call, findProcessor, the returned
match, and the processor (BaseFileProcessor.processFile) to locate where to add
these span attribute and exception-recording updates.
src/lib/rag/chunkers/BaseChunker.ts (1)

92-96: Duplicate span attributes.

rag.chunker.content_length and rag.chunker.content_chars are set to the same value (content.length) and convey identical information. Drop one (likely content_chars) to avoid schema noise in Langfuse/OTel.

♻️ Suggested change
         attributes: {
           "rag.chunker.strategy": this.strategy,
           "rag.chunker.content_length": content.length,
-          "rag.chunker.content_chars": content.length,
         },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/rag/chunkers/BaseChunker.ts` around lines 92 - 96, In BaseChunker
where the span attributes are set (attributes: { "rag.chunker.strategy":
this.strategy, "rag.chunker.content_length": content.length,
"rag.chunker.content_chars": content.length }), remove the duplicate attribute
"rag.chunker.content_chars" so only "rag.chunker.content_length" remains; update
any related comments/tests that reference content_chars and ensure any
telemetry/schema consumers use the single "rag.chunker.content_length" attribute
(class/method: BaseChunker attribute assignment).
src/lib/client/sseClient.ts (1)

697-699: Reconnect path produces deeply nested spans.

attemptReconnect calls this.stream(path, options, callbacks), which re-enters withClientSpan(...). Because the outer _streamInternal is still awaiting inside its span, each reconnect becomes a child of the previous attempt's span. With maxReconnectAttempts = 5 (default) this is tolerable, but for a long-lived flaky connection you'll get a 6-level-deep neurolink.client.sse.stream chain per logical stream.

Consider either:

  • calling this._streamInternal(...) directly from attemptReconnect (keeps a single span per logical stream), or
  • tagging the reconnect with sse.reconnect_attempt and letting each attempt be a sibling span instead of a child.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/client/sseClient.ts` around lines 697 - 699, The reconnect logic in
attemptReconnect calls this.stream(path, options, callbacks) which re-enters
withClientSpan and nests spans under the original _streamInternal span; change
attemptReconnect to call this._streamInternal(...) directly (preserving the same
span) or alter reconnection to create sibling spans by invoking withClientSpan
with a tag like "sse.reconnect_attempt" so reconnect attempts are not children
of the original span; update the code in the attemptReconnect function to use
either this._streamInternal(...) instead of this.stream(...) or wrap the
reconnect call in withClientSpan and add the "sse.reconnect_attempt" attribute,
keeping maxReconnectAttempts behavior intact.
src/lib/providers/amazonBedrock.ts (3)

2227-2238: Use this.providerName for consistency.

The new RateLimitError branch hardcodes the provider string to "bedrock", while the sibling AuthenticationError/ProviderError branches in the same method use this.providerName. Prefer the same pattern here so downstream consumers see a single canonical provider identifier.

🛠️ Suggested fix
       return new RateLimitError(
         `Bedrock rate limit (throttled): ${error instanceof Error ? error.message : String(error)}`,
-        "bedrock",
+        this.providerName,
       );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/providers/amazonBedrock.ts` around lines 2227 - 2238, The
RateLimitError branch in the Bedrock error mapping currently hardcodes the
provider string "bedrock"; update it to use the instance property
this.providerName for consistency with the AuthenticationError/ProviderError
branches—locate the block that checks errName/errCode for "ThrottlingException"
and change the provider argument passed to new RateLimitError to
this.providerName so downstream consumers get the canonical provider identifier.

284-304: Consider emitting generation:end on failure too.

generate() only emits generation:end on the success path. If conversationLoop() throws (Bedrock error, max-iterations, tool mapping mismatch, etc.), no event is emitted and Pipeline B never sees the failed generation — arguably the most important case for observability. A try/catch that emits success: false with the error classification before rethrowing would close that gap symmetrically with the new streaming path.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/providers/amazonBedrock.ts` around lines 284 - 304, The generate()
flow currently only emits generation:end after a successful conversationLoop()
call; wrap the call to this.conversationLoop(options) in a try/catch inside
generate(), and in the catch emit a generation:end via
this.neurolink?.getEventEmitter() with success: false, include responseTime
(Date.now() - generateStartTime), timestamp, result metadata (model:
this.modelName || this.getDefaultModel(), provider: this.providerName), and an
error classification/message, then rethrow the error so upstream behavior is
unchanged; ensure the same emitter path used in the success branch
(generateEmitter = this.neurolink?.getEventEmitter()) is used for failures for
symmetric observability.

1394-1483: Duplication: first-turn and continuation stream parsers are ~90% identical.

The inline first-iteration chunk parser (lines ~1396–1483) and processStreamResponse (lines ~1821–1925) now both maintain the _inputBuffer/toolUse reconstruction logic, the messageStop → continue pattern, and the metadata.usage break. Any future bug fix to stream-chunk handling has to be made in two places, and this PR already had to update both in parallel. Extracting a shared private helper (e.g., private async drainConverseStream(stream, controller): Promise<{ content, stopReason, usage }>) would collapse both call sites.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/providers/amazonBedrock.ts` around lines 1394 - 1483, Duplicate
stream-parsing logic exists between the inline first-iteration loop and
processStreamResponse; extract the common behavior into a private helper
(suggested name: drainConverseStream) that accepts the stream and controller and
returns an object like { contentBlocks: firstMessageContent, stopReason, usage:
{ inputTokens, outputTokens } }. Move shared handling for
contentBlockStart/contentBlockDelta/contentBlockStop, _inputBuffer/toolUse
reconstruction, messageStop → continue behavior, and metadata.usage break into
that helper, then replace the inline loop and the body of processStreamResponse
to call drainConverseStream(stream, controller) and consume its returned
contentBlocks, stopReason, and usage.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 569b1d2e-3229-47df-bd69-b13fbbfb669a

📥 Commits

Reviewing files that changed from the base of the PR and between 49f56cd and 769aedc.

📒 Files selected for processing (69)
  • docs/telemetry-after-proof.txt
  • docs/telemetry-baseline-proof.txt
  • docs/telemetry-fix-completion-report.md
  • docs/telemetry-fix-diff.txt
  • docs/telemetry-fix-execution-plan.md
  • docs/telemetry-fix-plan.md
  • docs/telemetry-gaps-audit.md
  • src/cli/loop/session.ts
  • src/lib/auth/anthropicOAuth.ts
  • src/lib/auth/sessionManager.ts
  • src/lib/auth/tokenStore.ts
  • src/lib/client/httpClient.ts
  • src/lib/client/sseClient.ts
  • src/lib/client/streamingClient.ts
  • src/lib/client/wsClient.ts
  • src/lib/context/budgetChecker.ts
  • src/lib/context/contextCompactor.ts
  • src/lib/context/summarizationEngine.ts
  • src/lib/core/baseProvider.ts
  • src/lib/core/modules/GenerationHandler.ts
  • src/lib/core/modules/StreamHandler.ts
  • src/lib/core/modules/ToolsManager.ts
  • src/lib/evaluation/ragasEvaluator.ts
  • src/lib/mcp/batching/requestBatcher.ts
  • src/lib/mcp/httpRateLimiter.ts
  • src/lib/mcp/httpRetryHandler.ts
  • src/lib/mcp/mcpClientFactory.ts
  • src/lib/mcp/toolDiscoveryService.ts
  • src/lib/mcp/toolRegistry.ts
  • src/lib/memory/memoryRetrievalTools.ts
  • src/lib/neurolink.ts
  • src/lib/observability/utils/spanSerializer.ts
  • src/lib/processors/base/BaseFileProcessor.ts
  • src/lib/processors/registry/ProcessorRegistry.ts
  • src/lib/providers/amazonBedrock.ts
  • src/lib/providers/amazonSagemaker.ts
  • src/lib/providers/anthropic.ts
  • src/lib/providers/azureOpenai.ts
  • src/lib/providers/googleAiStudio.ts
  • src/lib/providers/googleVertex.ts
  • src/lib/providers/huggingFace.ts
  • src/lib/providers/litellm.ts
  • src/lib/providers/mistral.ts
  • src/lib/providers/ollama.ts
  • src/lib/providers/openAI.ts
  • src/lib/providers/openRouter.ts
  • src/lib/providers/openaiCompatible.ts
  • src/lib/rag/chunkers/BaseChunker.ts
  • src/lib/rag/ragIntegration.ts
  • src/lib/rag/reranker/reranker.ts
  • src/lib/rag/retrieval/vectorQueryTool.ts
  • src/lib/server/middleware/common.ts
  • src/lib/server/routes/agentRoutes.ts
  • src/lib/server/routes/claudeProxyRoutes.ts
  • src/lib/server/routes/healthRoutes.ts
  • src/lib/server/routes/mcpRoutes.ts
  • src/lib/server/routes/memoryRoutes.ts
  • src/lib/server/routes/openApiRoutes.ts
  • src/lib/server/routes/toolRoutes.ts
  • src/lib/services/server/ai/observability/instrumentation.ts
  • src/lib/telemetry/traceContext.ts
  • src/lib/telemetry/tracers.ts
  • src/lib/types/span.ts
  • src/lib/utils/toolEndEmitter.ts
  • src/lib/workflow/core/ensembleExecutor.ts
  • src/lib/workflow/core/judgeScorer.ts
  • src/lib/workflow/core/responseConditioner.ts
  • src/lib/workflow/core/workflowRunner.ts
  • test/telemetry-gaps-verification.ts

Comment thread docs/telemetry-after-proof.txt Outdated
Comment thread src/lib/core/baseProvider.ts
Comment on lines +155 to +183
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);

// Emit discovery failed event
this.emit("discoveryFailed", {
serverId,
error: errorMessage,
timestamp: new Date(),
} satisfies ToolRegistryEvents["discoveryFailed"]);
mcpLogger.error(
`[ToolDiscoveryService] Discovery failed for ${serverId}:`,
error,
);

return {
success: false,
error: errorMessage,
toolCount: 0,
tools: [],
duration: Date.now() - startTime,
serverId,
};
} finally {
this.discoveryInProgress.delete(serverId);
}
// Emit discovery failed event
this.emit("discoveryFailed", {
serverId,
error: errorMessage,
timestamp: new Date(),
} satisfies ToolRegistryEvents["discoveryFailed"]);

return {
success: false,
error: errorMessage,
toolCount: 0,
tools: [],
duration: Date.now() - startTime,
serverId,
};
} finally {
this.discoveryInProgress.delete(serverId);
}
},
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Discovery failures won't mark the span as ERROR — same gap the T3 fix addresses in toolRegistry.ts.

The catch at line 155 converts exceptions into a returned { success: false, ... } result instead of rethrowing. Because withSpan only auto-detects errors via thrown exceptions, the neurolink.mcp.discoverTools span will be recorded with OK status for every failed discovery, hiding failures in Langfuse/OTel and diverging from the explicit setStatus(ERROR) + recordException pattern you just applied in src/lib/mcp/toolRegistry.ts (lines 554–562).

🛠️ Proposed fix
         } catch (error) {
           const errorMessage =
             error instanceof Error ? error.message : String(error);
 
+          span.setStatus({ code: SpanStatusCode.ERROR, message: errorMessage });
+          if (error instanceof Error) {
+            span.recordException(error);
+          }
+
           mcpLogger.error(
             `[ToolDiscoveryService] Discovery failed for ${serverId}:`,
             error,
           );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/mcp/toolDiscoveryService.ts` around lines 155 - 183, The catch in
ToolDiscoveryService's discovery handler converts exceptions into a returned
failure result so withSpan (neurolink.mcp.discoverTools) never sees a thrown
error and the span stays OK; update the catch block inside the discovery
function to call span.setStatus({ code: SpanStatusCode.ERROR }) and
span.recordException(error) (same pattern used in toolRegistry.ts) and then
rethrow the error (instead of just returning { success: false, ... }), while
still emitting the "discoveryFailed" event and ensuring
discoveryInProgress.delete(serverId) runs in finally; this ensures the withSpan
wrapper records the error status and exception for neurolink.mcp.discoverTools.

Comment thread src/lib/memory/memoryRetrievalTools.ts
Comment thread src/lib/neurolink.ts
Comment on lines +69 to +85
// Validate weights sum to 1.0
const totalWeight =
(weights.semantic || DEFAULT_WEIGHTS.semantic) +
(weights.vector || DEFAULT_WEIGHTS.vector) +
(weights.position || DEFAULT_WEIGHTS.position);

if (Math.abs(totalWeight - 1.0) > 0.01) {
logger.warn("[Reranker] Weights do not sum to 1.0, normalizing", {
original: weights,
total: totalWeight,
});
}

const normalizedWeights = {
semantic: (weights.semantic || DEFAULT_WEIGHTS.semantic) / totalWeight,
vector: (weights.vector || DEFAULT_WEIGHTS.vector) / totalWeight,
position: (weights.position || DEFAULT_WEIGHTS.position) / totalWeight,
};

const rerankedResults: RerankResult[] = [];

// Process results in parallel batches for efficiency
const batchSize = 5;
for (let i = 0; i < results.length; i += batchSize) {
const batch = results.slice(i, i + batchSize);
const batchPromises = batch.map(async (result, batchIndex) => {
const globalIndex = i + batchIndex;

// Calculate vector score (use existing score or 0)
const vectorScore = result.score ?? 0;

// Calculate position score (inverse of position)
const positionScore = 1 - globalIndex / results.length;

// Calculate semantic score using LLM
const semanticResult = await calculateSemanticScore(
query,
result.text || (result.metadata?.text as string) || "",
model,
);

// Combine scores
const combinedScore =
normalizedWeights.semantic * semanticResult.score +
normalizedWeights.vector * vectorScore +
normalizedWeights.position * positionScore;

return {
result,
score: combinedScore,
details: {
semantic: semanticResult.score,
vector: vectorScore,
position: positionScore,
queryAnalysis: semanticResult.analysis,
},
const normalizedWeights = {
semantic: (weights.semantic || DEFAULT_WEIGHTS.semantic) / totalWeight,
vector: (weights.vector || DEFAULT_WEIGHTS.vector) / totalWeight,
position: (weights.position || DEFAULT_WEIGHTS.position) / totalWeight,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Honor explicit zero weights.

These blocks use || to apply defaults, so a caller cannot intentionally set semantic, vector, or position to 0. For example, { semantic: 0, vector: 1, position: 0 } gets rewritten to the defaults instead of disabling semantic/position scoring. Use ?? for defaulting and reject totalWeight <= 0.

Also applies to: 227-237

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/rag/reranker/reranker.ts` around lines 69 - 85, The weight
normalization uses || which treats explicit zeros as absent; change the
defaulting to use nullish coalescing (weights.semantic ??
DEFAULT_WEIGHTS.semantic, weights.vector ?? DEFAULT_WEIGHTS.vector,
weights.position ?? DEFAULT_WEIGHTS.position) when computing totalWeight and
when building normalizedWeights, and add a guard that rejects or throws if
totalWeight <= 0 (instead of dividing by zero) with a clear error/log message
via logger.warn or throw; apply the same change to the analogous block around
the code referenced at lines ~227-237 in reranker.ts.

Comment on lines +57 to +65
return withSpan(
{
name: "neurolink.http.execute",
tracer: tracers.http,
attributes: {
"http.route": "/api/agent/execute",
"ai.provider": request.provider || "default",
"ai.model": request.model || "default",
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Use basePath in the new http.route attributes.

These handlers are mounted with a configurable basePath, but the spans are hardcoded to /api/.... Any deployment that mounts the routes elsewhere will emit the wrong route label and split telemetry across otherwise identical endpoints.

Suggested fix
-                "http.route": "/api/agent/execute",
+                "http.route": `${basePath}/agent/execute`,
...
-                "http.route": "/api/agent/stream",
+                "http.route": `${basePath}/agent/stream`,
...
-                  "http.route": "/api/agent/embed",
+                  "http.route": `${basePath}/agent/embed`,
...
-                  "http.route": "/api/agent/embed-many",
+                  "http.route": `${basePath}/agent/embed-many`,

Also applies to: 139-147, 229-237, 292-301

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/server/routes/agentRoutes.ts` around lines 57 - 65, The spans in
agentRoutes.ts set the "http.route" attribute to hardcoded "/api/..." values
(see the withSpan calls using tracers.http and attributes with "http.route"),
which breaks telemetry when routes are mounted under a configurable basePath;
update each withSpan that currently sets "http.route" (including the occurrences
for execute, and the other two blocks in this file) to build the route using the
configured basePath plus the route suffix (e.g., `${basePath}/agent/execute` or
similar) rather than a hardcoded string; locate the withSpan invocations and
modify the attributes object to reference the basePath variable used for
mounting the handlers and concatenate the route path so the span uses the actual
mounted route.

Comment thread src/lib/server/routes/claudeProxyRoutes.ts Outdated
Comment on lines +73 to +104
attributes: {
"http.route": `${basePath}/tools/search`,
"tools.search.query": (ctx.query.q as string) ?? "",
},
},
async () => {
const { q, source, limit } = ctx.query;
const tools = await ctx.toolRegistry.listTools();

let filtered = tools;
let filtered = tools;

// Filter by search query
if (q) {
const query = q.toLowerCase();
filtered = filtered.filter(
(tool) =>
tool.name.toLowerCase().includes(query) ||
(tool.description &&
tool.description.toLowerCase().includes(query)),
);
}
// Filter by search query
if (q) {
const query = q.toLowerCase();
filtered = filtered.filter(
(tool) =>
tool.name.toLowerCase().includes(query) ||
(tool.description &&
tool.description.toLowerCase().includes(query)),
);
}

// Filter by source
if (source) {
filtered = filtered.filter(
(tool) => (tool.source || "built-in") === source,
);
}
// Filter by source
if (source) {
filtered = filtered.filter(
(tool) => (tool.source || "built-in") === source,
);
}

// Apply limit
const maxResults = limit ? parseInt(limit, 10) : 50;
filtered = filtered.slice(0, maxResults);
// Apply limit
const maxResults = limit ? parseInt(limit, 10) : 50;
filtered = filtered.slice(0, maxResults);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Normalize query params before using them as strings.

Line 86 assumes q is a string, and Line 103 assumes limit is a parseable scalar. In most query parsers those values can also be arrays, so a request like ?q=a&q=b will throw on toLowerCase(), and malformed limit values can collapse the result set unexpectedly. Validate or normalize the query params before filtering.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/server/routes/toolRoutes.ts` around lines 73 - 104, Normalize and
validate query params before using them: extract q, source, and limit from
ctx.query as strings/numbers (e.g., if Array.isArray(q) use q[0] or default to
"", likewise for source), convert the normalized q toLowerCase() only after
ensuring it's a string, and parse limit with parseInt then validate it (fallback
to a safe default like 50 if parseInt returns NaN or a non-positive value)
before slicing filtered; update the search handler that uses ctx.query, q,
source, limit and ctx.toolRegistry.listTools to perform these checks so
multi-valued or malformed query params do not throw or unexpectedly collapse
results.

Comment thread test/telemetry-gaps-verification.ts Outdated
@murdore

murdore commented Apr 17, 2026

Copy link
Copy Markdown
Contributor Author

Review Feedback Addressed (Cycle 1)

Fixes Applied (14 of 25 comments addressed)

Span error status on returned errors (Comments #4, #5, #7, #8, #10)

  • src/lib/mcp/toolDiscoveryService.ts — catch block now calls span.setStatus(ERROR) + span.recordException() before returning { success: false }
  • src/lib/server/routes/toolRoutes.ts — Both /tools/execute and /tools/:name/execute handlers: tool-not-found and execution-error paths now set SpanStatusCode.ERROR on span before returning { success: false }
  • src/lib/memory/memoryRetrievalTools.ts — All 5 error-return paths (artifact store missing, artifact not found, sessionId missing, memoryManager missing, generic catch) now call otelSpan.setStatus(ERROR) + otelSpan.recordException() where applicable

Don't fall back to fake streaming on terminal errors (Comment #9 — Critical)

  • src/lib/core/baseProvider.ts — Added guard before fake-streaming fallback: AbortError, timeout, auth (401/403), quota, and rate-limit errors are now rethrown immediately instead of triggering a second upstream request

Stream generation:end success flag accuracy (Comments #13, #15)

  • src/lib/providers/amazonBedrock.ts — Wrapped stream iterable now tracks streamErrored flag in catch; generation:end emits success: !streamErrored and finishReason: "error" on failure
  • src/lib/providers/ollama.ts — Same fix: ollamaStreamErrored flag tracked in catch blocks (iteration limit + general error); generation:end emits accurate success/finishReason

Dead stream finishReason code (Comment #11)

  • src/lib/neurolink.ts — Both stream:complete emit sites now include finishReason in both the top-level data AND metadata objects, sourced from streamState.finishReason

Proxy response redaction (Comment #17)

  • src/lib/server/routes/claudeProxyRoutes.ts — Both 429 and auth-retry error logging paths now: strip authorization/x-api-key headers before persisting, cap body to 4000 chars with ...[truncated]

Verification test exit code (Comment #19)

  • test/telemetry-gaps-verification.ts — Exit code now based on confirmed > 0 (gaps still exist = fail), not failed > 0 (gaps fixed = success)

Proof artifact inconsistency (Comment #18)

  • docs/telemetry-after-proof.txt — A3 summary line corrected from CONFIRMED to SKIP (pattern removed)

Tool retry count on failed retries (Review body — RB-6)

  • src/lib/neurolink.ts — toolRetryCount variable hoisted outside try block; tool.retry_count attribute now set in both success AND catch paths

WebSocket span leak (Review body — RB-4)

  • src/lib/client/wsClient.ts — connect() now: (1) ends orphaned spans from prior attempts before creating new one, (2) wraps WebSocket constructor in try/catch that ends span on throw

Intentionally Deferred (5 items)

Dismissed (6 items)

  • RB-1 (tokenStore auth.refreshed) — No-refresher path is a valid early exit; span OK is correct
  • RB-2 (ensembleExecutor OK on 0 success) — Pre-existing design; result-based, not exception-based
  • RB-3 (wsClient onerror span leak) — Code comment explains: "onclose will fire next" per WebSocket spec
  • RB-5 (pipelineAHandled kills Pipeline B) — FALSE POSITIVE: native providers emit their own generation:end WITHOUT the flag; finalizeGenerateRequestResult's emit WITH the flag is correctly skipped. Pipeline B only processes the native provider's event.
  • Comment fix(cli): prevent debug log persistence in production deployments #14 (Vertex empty content) — Streaming content isn't accumulated; design limitation
  • Docstring coverage — Pre-existing, not introduced by this PR

Verification

  • TypeScript: ✅ 0 errors / 3637 files
  • Lint: ✅ 0 errors (12 pre-existing warnings)
  • Tests: ✅ 48/48 pass

Requesting Re-review

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Apr 17, 2026

Copy link
Copy Markdown

@murdore, thank you for the thorough Cycle 1 summary. The fixes look comprehensive — particularly the terminal-error guard in baseProvider.ts (critical RB item), the streamErrored flag pattern in Bedrock/Ollama, and the WebSocket span-leak double-fix (orphan cleanup + constructor try/catch).

A few notes on the dismissed items I'll record for future reviews:

  • RB-5 (pipelineAHandled): Understood — native providers (Bedrock, Ollama, Gemini, Vertex) emit generation:end without the flag directly on their own path; only finalizeGenerateRequestResult emits with pipelineAHandled: true to gate the SDK-level duplicate. Pipeline B correctly processes only the native provider event.
  • RB-3 (wsClient onerror span leak): Confirmed per the WebSocket spec — onerror always fires immediately before onclose, so onclose reliably ends the span.
  • Comment #12 (PII in file.name): Acknowledged as a broader logging policy decision rather than a gap introduced by this PR.

Triggering the full re-review now.


✏️ Learnings added
Learnt from: murdore
URL: https://github.com/juspay/neurolink/pull/962

Timestamp: 2026-04-17T04:30:25.500Z
Learning: In neurolink (src/lib/neurolink.ts), the `pipelineAHandled: true` flag on `generation:end` events is set ONLY by `finalizeGenerateRequestResult` (the SDK-level wrapper). Native providers (amazonBedrock.ts, ollama.ts, googleAiStudio.ts, googleVertex.ts) emit their own `generation:end` events WITHOUT this flag. The `initializeMetricsListeners()` early-return guard `if (data.pipelineAHandled) return` correctly prevents duplicates: it skips the SDK-level event while allowing native provider events through to Pipeline B.

Learnt from: murdore
URL: https://github.com/juspay/neurolink/pull/962

Timestamp: 2026-04-17T04:30:25.501Z
Learning: In src/lib/client/wsClient.ts, the `onerror` handler intentionally does NOT end the OTel `connectionSpan` because the WebSocket spec guarantees that `onclose` always fires immediately after `onerror`. The span lifecycle is: `onerror` records exception + sets ERROR status → `onclose` ends the span. This is by design, not a span leak.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

✅ Actions performed

Full review triggered.

@github-actions

Copy link
Copy Markdown
Contributor

🤖 AI Review & Build Compliance ✅

Status: AI analysis complete • Build rules validated • Ready for review

📊 View detailed analysis results

🛡️ Analysis Complete

  • ✅ Security scan (vulnerabilities, API keys)
  • ✅ TypeScript safety & code quality
  • ✅ Error handling & best practices
  • ✅ Build rule enforcement validated
  • ✅ Commit format & compliance checks

📋 Ready for Merge When

  • All CI checks passing
  • Manual review approved
  • Any AI-flagged issues resolved

🤖 AI analysis complete - check individual code comments for specific feedback

@murdore

murdore commented Apr 17, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai have you completed the full comprehensive review or not?

@coderabbitai

coderabbitai Bot commented Apr 17, 2026

Copy link
Copy Markdown

@murdore — The full review was triggered at the end of my previous response (Cycle 1 acknowledgment). If you haven't seen the review comments appear yet, it may still be processing or may have been queued. I'll re-trigger it now to make sure it runs.

✅ Actions performed

Full review triggered.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 13

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (5)
src/lib/client/httpClient.ts (1)

506-547: ⚠️ Potential issue | 🟡 Minor

stream() path is not wrapped with withClientSpan.

The PR instruments request() (and per the summary, sseClient/streamingClient/wsClient), but NeuroLinkClient.stream() here bypasses request() and issues a direct fetch against /api/agent/stream. As a result, client-side SSE streaming requests don't produce an http.* span and won't show up in the Pipeline-A trace alongside other client transports. Consider wrapping the fetch+read loop in withClientSpan using tracers.http for consistency with the rest of the telemetry coverage added in this PR.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/client/httpClient.ts` around lines 506 - 547, The stream()
implementation issues a direct fetch and should be wrapped in withClientSpan
using tracers.http (like other transports) so SSE calls produce an http.* span;
modify the async body of stream() (the fetch + response-read/error handling
logic that uses this.config.fetch, requestId, and response.ok) to run inside
withClientSpan(tracers.http, { attributes: { "http.method": "POST", "http.url":
`${this.config.baseUrl}/api/agent/stream`, "request.id": requestId }}, async
(span) => { ... }), ensure you propagate the original signal, capture and record
error attributes on span before rethrowing, and close/end the span in a finally
block so spans are created for NeuroLinkClient.stream() similarly to
request()/sseClient/streamingClient/wsClient.
src/lib/providers/openAI.ts (1)

451-526: ⚠️ Potential issue | 🟡 Minor

Potential span leak if post-streamText setup throws.

streamSpan is only ended synchronously in the inner catch (lines 513–526) or asynchronously when result.text settles (lines 562–572). If an exception is thrown between streamText() returning and the function returning (e.g., inside createOpenAITransformedStream construction, streamAnalyticsCollector.createAnalytics, or any future addition), the outer catch at line 613 rethrows without ending streamSpan, and since the caller never receives result the stream may never be consumed — leaving the span open.

Consider ending streamSpan in the outer catch as well (guarded by a "not yet ended" flag), or move the span.end() into a finally wired to a consumed/error signal.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/providers/openAI.ts` around lines 451 - 526, The streamSpan created
before calling streamText can remain open if an exception is thrown after
streamText returns but before the returned stream is consumed; update the error
handling so streamSpan is always ended: add a guarded "ended" flag (or similar)
that you set when you call streamSpan.end() (currently done inside the inner
catch and when result.text settles), then in the outer catch (the one that
currently rethrows without ending the span) call streamSpan.end() if not already
ended; alternatively move the span.end() into a finally block tied to the stream
consumption lifecycle (e.g., in the completion/rejection handlers created by
createOpenAITransformedStream, streamAnalyticsCollector.createAnalytics, or when
awaiting result.text) so streamSpan is reliably closed even if post-streamText
setup throws. Ensure references: streamSpan, streamText, result.text,
createOpenAITransformedStream, streamAnalyticsCollector.createAnalytics.
src/lib/providers/googleAiStudio.ts (1)

1285-1327: ⚠️ Potential issue | 🟡 Minor

finishReason misreports max_steps at the boundary in executeNativeGemini3Generate.

step >= maxSteps ? "max_steps" : "stop" (lines 1302, 1323) is wrong when the loop completes normally on the final allowed iteration: step is incremented at the top of each iteration, so breaking successfully on the last iteration leaves step === maxSteps and gets reported as max_steps even though the model stopped on its own. The stream path correctly tracks this via completedWithFinalAnswer; please mirror that here.

🛠️ Proposed fix
           let finalText = "";
           let lastStepText = "";
           let totalInputTokens = 0;
           let totalOutputTokens = 0;
+          let completedWithFinalAnswer = false;
...
               // If no function calls, we're done
               if (chunkResult.stepFunctionCalls.length === 0) {
                 finalText = stepText;
+                completedWithFinalAnswer = true;
                 break;
               }
...
-          finalText = handleMaxStepsTermination(
+          const hitStepLimitWithoutFinalAnswer =
+            step >= maxSteps && !completedWithFinalAnswer;
+          finalText = handleMaxStepsTermination(
             "[GoogleAIStudio]",
             step,
             maxSteps,
             finalText,
             lastStepText,
           );
...
-          span.setAttribute(
-            ATTR.GEN_AI_FINISH_REASON,
-            step >= maxSteps ? "max_steps" : "stop",
-          );
+          span.setAttribute(
+            ATTR.GEN_AI_FINISH_REASON,
+            hitStepLimitWithoutFinalAnswer ? "max_steps" : "stop",
+          );
...
-                finishReason: step >= maxSteps ? "max_steps" : "stop",
+                finishReason: hitStepLimitWithoutFinalAnswer
+                  ? "max_steps"
+                  : "stop",
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/providers/googleAiStudio.ts` around lines 1285 - 1327, In
executeNativeGememi3Generate the finishReason is computed using step >= maxSteps
which misreports normal completion on the final allowed iteration; change the
logic to use the same completedWithFinalAnswer flag as the stream path (or a
boolean that’s set when the model returns a final answer) to decide finishReason
("stop" when completedWithFinalAnswer is true, otherwise "max_steps"), and
update both the span.setAttribute(ATTR.GEN_AI_FINISH_REASON, ...) call and the
nativeGenerateEmitter.emit payload (result.finishReason) to use that flag
instead of comparing step and maxSteps so the finish reason matches the stream
path.
src/lib/providers/ollama.ts (1)

959-1138: ⚠️ Potential issue | 🟠 Major

Mirror this manual generation:end emission in the non-tool stream path too.

This closes the gap for executeStreamWithTools(), but executeStreamWithoutTools() still bypasses the AI SDK without emitting an equivalent generation:end. Plain Ollama streams will therefore keep missing the Pipeline B generation observation and usage/accounting that tool-enabled streams now get.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/providers/ollama.ts` around lines 959 - 1138, The non-tool path
(executeStreamWithoutTools) must mirror the manual generation:end emission and
analytics resolution you added for the tool path: in executeStreamWithoutTools
(the stream's finally/cleanup block) capture the same instance refs
(ollamaNeurolink, this.providerName, this.modelName || FALLBACK_OLLAMA_MODEL),
build aggregatedUsage from totalInputTokens/totalOutputTokens, call
resolveAnalytics(createAnalytics(...)) with the same metadata (requestId like
`ollama-stream-${Date.now()}`, streamingMode: true, iterations: iteration,
response time from startTime), then if ollamaNeurolink?.getEventEmitter() exists
emit "generation:end" with the same payload shape (provider, responseTime,
timestamp, result: { content: "", usage: aggregatedUsage, model: modelName,
provider, finishReason: ollamaStreamErrored ? "error" : (lastFinishReason ??
"stop") }, success: !ollamaStreamErrored); ensure you reference the same local
variables (totalInputTokens, totalOutputTokens, lastFinishReason,
ollamaStreamErrored, iteration, startTime) so Pipeline B receives the generation
observation for non-tool streams too.
src/lib/neurolink.ts (1)

6518-6528: ⚠️ Potential issue | 🟠 Major

Fallback stream completions still emit stale telemetry.

These payloads can still misreport successful fallback streams: in the zero-chunk fallback path, chunkCount never includes fallback chunks, and in the error-fallback path the emitted completion hard-codes chunkCount: 0, finishReason: "stop", and model: options.model. The resulting Pipeline B span can look empty or be attributed to the wrong termination reason/model even when fallback content streamed successfully. Feed the actual fallback chunk count, finish reason, and fallbackStreamResult.model into the emitted stream:complete event.

Also applies to: 7692-7703

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/neurolink.ts` around lines 6518 - 6528, The emitted stream completion
payload currently uses stale values (chunkCount, finishReason, model) when a
fallback stream path is used; update the stream:complete emission logic (the
block that builds the completion object using streamState, chunkCount,
accumulatedContent, sessionId, resolvedUsage) to prefer values from the fallback
result when present—specifically replace hard-coded or pre-fallback values with
fallbackStreamResult.chunkCount (or computed fallbackChunkCount),
fallbackStreamResult.finishReason (or streamState.finishReason if fallback
absent), and fallbackStreamResult.model (instead of options.model) so zero-chunk
and error-fallback paths report the actual fallbackChunkCount, finishReason, and
model; apply the same fix in the equivalent emission at the other occurrence
around the 7692-7703 region.
♻️ Duplicate comments (8)
src/lib/providers/googleVertex.ts (1)

2200-2225: ⚠️ Potential issue | 🟠 Major

Stream generation:end still drops the generated text.

This event always sends result.content: "", so Pipeline B/Langfuse records a successful generation with no output even when the stream produced text. Capture the terminal stepText (or accumulate streamed text) and emit that instead.

Suggested fix
-          if (chunkResult.stepFunctionCalls.length === 0) {
-            completedWithFinalAnswer = true;
-            break;
-          }
+          if (chunkResult.stepFunctionCalls.length === 0) {
+            lastStepText = stepText;
+            completedWithFinalAnswer = true;
+            break;
+          }
...
-            content: "",
+            content: lastStepText,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/providers/googleVertex.ts` around lines 2200 - 2225, The emitted
"generation:end" currently sends result.content: "" which discards the streamed
output; change the emit to include the final generated text by using the
terminal stepText (or the accumulator that collects streamed tokens, e.g.,
streamedText/accumulatedText) instead of an empty string, so in the block where
vertexStreamEmitter.emit("generation:end", {...}) set result.content to the
final assembled text (trim/join if you accumulate per-step) and keep the rest of
the payload (provider, usage, model, finishReason) unchanged; reference the
existing vertexStreamEmitter, params.maxSteps, step, completedWithFinalAnswer
and the variable that holds per-step text (stepText or your streaming
accumulator) when implementing the fix.
docs/telemetry-after-proof.txt (1)

69-73: ⚠️ Potential issue | 🟠 Major

A4 is still summarized incorrectly.

The detailed section shows one confirmed A4 test and one not-confirmed A4 test, but the per-gap summary collapses A4 to NOT CONFIRMED. That makes the proof artifact misleading again.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/telemetry-after-proof.txt` around lines 69 - 73, The per-gap summary
under the "Per-gap summary" block incorrectly collapses mixed-status gaps (like
A4) to NOT CONFIRMED; update the summary-generation logic (e.g., the function
generatePerGapSummary or renderPerGapSummary and the gapSummaries/gaps
aggregation code) to aggregate test results per gap and choose the summary
status by checking counts in this order: if any CONFIRMED exists mark CONFIRMED,
else if any SKIP and no CONFIRMED mark SKIP, else if any NOT CONFIRMED mark NOT
CONFIRMED; ensure the A4 aggregation uses these counts rather than collapsing
mixed results to NOT CONFIRMED.
src/lib/server/routes/toolRoutes.ts (1)

80-116: ⚠️ Potential issue | 🟡 Minor

Query params still treated as string/parseable without normalization.

Same observation from the prior cycle: ctx.query.q, source, and limit can be arrays in most query parsers, and parseInt on NaN silently yields an unexpected 0-result slice. Cast at line 76 ((ctx.query.q as string) ?? "") also crashes toLowerCase() if q arrives as an array. Worth normalizing in one place up front.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/server/routes/toolRoutes.ts` around lines 80 - 116, Normalize and
validate query params from ctx.query up front: coerce q, source and limit to
single string values before using them (e.g., ensure q is a single string before
calling toLowerCase, ensure source is a string before comparing, and parseInt
limit safely). In the handler that calls ctx.toolRegistry.listTools() use a
short normalization block that handles array inputs (take first element),
defaults (q -> "", source -> "built-in"), and safe integer parsing for limit
(fallback to 50 when parseInt yields NaN or non-positive), then use the
normalized q, source, and maxResults variables in the filtering and slicing
logic to avoid crashes and unexpected 0-length slices.
src/lib/server/routes/agentRoutes.ts (1)

62-65: ⚠️ Potential issue | 🟠 Major

http.route still hardcoded to /api/... across all four endpoints.

Same finding as the prior cycle: the prefix is computed from the caller-provided basePath, but the span attribute hardcodes /api/agent/.... Any mount under a different basePath will split telemetry. Since the PR scope is full OTel coverage, worth fixing now rather than re-deferring.

🛠️ Proposed fix
-                "http.route": "/api/agent/execute",
+                "http.route": `${basePath}/agent/execute`,
...
-                "http.route": "/api/agent/stream",
+                "http.route": `${basePath}/agent/stream`,
...
-                  "http.route": "/api/agent/embed",
+                  "http.route": `${basePath}/agent/embed`,
...
-                  "http.route": "/api/agent/embed-many",
+                  "http.route": `${basePath}/agent/embed-many`,

Also applies to: 144-147, 234-237, 297-301

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/server/routes/agentRoutes.ts` around lines 62 - 65, The telemetry
span attribute "http.route" is hardcoded to "/api/agent/..." causing split
traces when the router is mounted under a different basePath; update the span
attribute to use the computed mount prefix instead of the literal string. Locate
the places in agentRoutes.ts where "http.route" is set (inside the execute
handler and the three other endpoints referenced) and replace the literal
"/api/agent/..." with the dynamic prefix (the same value used to build the
route, e.g., the computed basePath/prefix or the existing variable used to mount
routes) concatenated with the endpoint path (for example
`${prefix}/agent/execute`), or alternatively derive it from
request.baseUrl/request.originalUrl consistently; apply this change to all four
occurrences (the blocks around the execute handler and the three other endpoint
handlers).
src/lib/rag/reranker/reranker.ts (1)

69-85: ⚠️ Potential issue | 🟠 Major

Honor explicit zero weights and reject zero-total configs.

Lines 71-85 and Line 229-Line 237 still use || for defaulting, so { semantic: 0, vector: 1, position: 0 } gets rewritten to the defaults, and { semantic: 0, vector: 0, position: 0 } will divide by zero during normalization.

Suggested fix
-      const totalWeight =
-        (weights.semantic || DEFAULT_WEIGHTS.semantic) +
-        (weights.vector || DEFAULT_WEIGHTS.vector) +
-        (weights.position || DEFAULT_WEIGHTS.position);
+      const semanticWeight = weights.semantic ?? DEFAULT_WEIGHTS.semantic;
+      const vectorWeight = weights.vector ?? DEFAULT_WEIGHTS.vector;
+      const positionWeight = weights.position ?? DEFAULT_WEIGHTS.position;
+      const totalWeight =
+        semanticWeight + vectorWeight + positionWeight;
+
+      if (totalWeight <= 0) {
+        throw new Error("Reranker weights must sum to a value greater than 0");
+      }
 
       if (Math.abs(totalWeight - 1.0) > 0.01) {
         logger.warn("[Reranker] Weights do not sum to 1.0, normalizing", {
           original: weights,
           total: totalWeight,
         });
       }
 
       const normalizedWeights = {
-        semantic: (weights.semantic || DEFAULT_WEIGHTS.semantic) / totalWeight,
-        vector: (weights.vector || DEFAULT_WEIGHTS.vector) / totalWeight,
-        position: (weights.position || DEFAULT_WEIGHTS.position) / totalWeight,
+        semantic: semanticWeight / totalWeight,
+        vector: vectorWeight / totalWeight,
+        position: positionWeight / totalWeight,
       };
#!/bin/bash
node - <<'NODE'
const defaults = { semantic: 0.4, vector: 0.4, position: 0.2 };
const weights = { semantic: 0, vector: 1, position: 0 };

const withOr = {
  semantic: weights.semantic || defaults.semantic,
  vector: weights.vector || defaults.vector,
  position: weights.position || defaults.position,
};

const withNullish = {
  semantic: weights.semantic ?? defaults.semantic,
  vector: weights.vector ?? defaults.vector,
  position: weights.position ?? defaults.position,
};

console.log({ withOr, withNullish, zeroTotal: 0 / 0 });
NODE

Also applies to: 227-237

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/rag/reranker/reranker.ts` around lines 69 - 85, The weights
normalization code incorrectly uses || which treats explicit zero as missing and
can produce divide-by-zero; update all defaulting of weights (where
weights.semantic/vector/position are combined with DEFAULT_WEIGHTS and where
normalizedWeights is computed — e.g. in reranker.ts around the
weights/normalizedWeights logic and the similar block at lines ~227-237) to use
the nullish coalescing operator (??) so explicit zeros are honored, then
validate totalWeight after summing and throw or return an error if totalWeight
=== 0 (or very near zero) instead of dividing; ensure logger.warn still runs
when totalWeight deviates from 1.0 but reject zero-total configs explicitly.
src/lib/memory/memoryRetrievalTools.ts (1)

243-255: ⚠️ Potential issue | 🟠 Major

These not-found returns still bypass the outer span error status.

The new wrapper only sees a normal return in the session-not-found and message-not-found branches, so neurolink.memory.retrieve_context will still be exported as successful unless you also mark otelSpan as failed here.

Also applies to: 261-270

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/memory/memoryRetrievalTools.ts` around lines 243 - 255, The not-found
early returns (when memoryManager.getSessionRaw(sessionId) or message lookup
fails) currently end and record an internal span via SpanSerializer.endSpan but
do not mark the outer otelSpan as failed, so neurolink.memory.retrieve_context
is exported as successful; update both the session-not-found branch (around
getSessionRaw() handling) and the message-not-found branch (lines ~261-270) to
set the otelSpan status to error (include a descriptive message like `Session
not found: ${sessionId}` or `Message not found: ${messageId}`) before
ending/recording spans, using the same SpanStatus.ERROR semantics you already
use with SpanSerializer.endSpan and getMetricsAggregator().recordSpan to ensure
the outer span is exported as failed.
src/lib/core/baseProvider.ts (1)

261-300: ⚠️ Potential issue | 🔴 Critical

The real-stream fallback is still too broad for terminal failures.

This still falls back to executeFakeStreaming() for any stream error that doesn't match these exact lowercase substrings. A typed RateLimitError, AuthenticationError, TimeoutError, or even "Rate Limit"/"Authentication" message will slip through and trigger a second upstream request. The fallback needs to be gated by an explicit “streaming-with-tools unsupported” classification, not by negative string matching.

Based on learnings, src/lib/core/baseProvider.ts is the base class all providers extend. Central stream() method merges tools before calling provider-specific executeStream().

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/core/baseProvider.ts` around lines 261 - 300, The current catch in
the stream flow (in BaseProvider.stream that calls executeStream) must stop
using negative substring matching and only fall back to executeFakeStreaming
when the error is explicitly a “streaming-with-tools unsupported” signal; update
the catch to: treat AbortError/TimeoutError/RateLimitError/AuthenticationError
(or any error with a dedicated type/property like instanceof RateLimitError,
AuthenticationError, TimeoutError, or a boolean flag such as
error.isStreamingNotSupported or error.code === 'STREAMING_UNSUPPORTED') as
terminal and rethrow via handleProviderError, and only call
executeFakeStreaming(options, analysisSchema) when the provider explicitly
indicates streaming-with-tools is unsupported (e.g., instanceof
StreamingNotSupportedError or error.isStreamingNotSupported === true); keep
supportsTools() gating in place and ensure executeStream implementations can
surface the explicit streaming-unsupported signal.
src/lib/server/routes/claudeProxyRoutes.ts (1)

3296-3322: ⚠️ Potential issue | 🟠 Major

Sanitize upstream error bodies before persisting these diagnostics.

These paths still write raw provider response bodies into tracer/log snapshots. Stripping authorization/x-api-key headers and truncating at 4 KB does not prevent echoed secrets, prompts, or other PII from being stored. Please run the body through the logging sanitizer before tracer.logUpstreamResponseBody() / logProxyBody(), and derive bodySize from the sanitized payload instead of the original body.

As per coding guidelines "Use transformParamsForLogging() to safely strip secrets before logging."

Also applies to: 4245-4273

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/server/routes/claudeProxyRoutes.ts` around lines 3296 - 3322, The
retry path is persisting raw upstream response bodies; run the response body
through the existing logging sanitizer (transformParamsForLogging) before
calling tracer.logUpstreamResponseBody and before passing body to logProxyBody,
replace cappedRetryBody with the sanitized+truncated payload, and compute
bodySize from the sanitized string (not retryBody); keep the header redaction
intact, and apply the same change for the duplicate block around the other
occurrence (lines ~4245-4273) so all upstream response logging uses
transformParamsForLogging and derived size from the sanitized output.
🟡 Minor comments (12)
src/lib/processors/base/BaseFileProcessor.ts-151-235 (1)

151-235: ⚠️ Potential issue | 🟡 Minor

Span is not marked as errored on failure return paths.

When validation/download/post-validation returns { success: false, error }, the callback returns normally, so the span is left with OK/unset status even though the operation failed. Same for the outer catch which returns an error object rather than rethrowing. Consumers looking at failed file processing in Langfuse/OTel won't see ERROR status here. Consider _span.setStatus({ code: SpanStatusCode.ERROR, message: ... }) (and optionally _span.recordException) on the failure branches, matching the error-propagation fixes applied to tool discovery / server routes / memory retrieval in cycle 1.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/processors/base/BaseFileProcessor.ts` around lines 151 - 235, The
span created in the anonymous async handler is never marked errored on failure
paths; update the failure branches in the handler (where validateFileWithResult,
downloadFileWithRetry, validateDownloadedFileWithResult return { success: false,
error } and where you return createError(FileErrorCode.*)) to call
_span.setStatus({ code: SpanStatusCode.ERROR, message: error?.message ||
String(error) }) and optionally _span.recordException(error) before returning,
and in the catch block set the span status/recordException and then rethrow the
error (or re-throw after wrapping) instead of returning the error object so the
span accurately reflects failures for validateFileWithResult,
downloadFileWithRetry, validateDownloadedFileWithResult, and
buildProcessedResultWithResult paths.
src/lib/server/routes/memoryRoutes.ts-19-36 (1)

19-36: ⚠️ Potential issue | 🟡 Minor

Span stays OK for server-side error responses — propagate error codes to set ERROR status.

Handlers return createErrorResponse(...) envelopes instead of throwing (e.g. MEMORY_ERROR from caught exceptions at lines 155-162, 255-262). tracedMemoryHandler passes a callback that doesn't use the span parameter, so the wrapping span is recorded with OK status despite the route returning a 5xx-equivalent error payload. Only thrown exceptions trigger ERROR status in withSpan (line 21); returned error objects are treated as successful results (line 19).

🛠️ Proposed fix
 function tracedMemoryHandler<T>(
   name: string,
   route: string,
   fn: (ctx: ServerContext) => Promise<T>,
 ): (ctx: ServerContext) => Promise<T> {
   return (ctx: ServerContext) =>
     withSpan(
       {
         name,
         tracer: tracers.http,
         attributes: {
           "http.route": route,
           "http.request.id": ctx.requestId,
         },
       },
-      () => fn(ctx),
+      async (span) => {
+        const result = await fn(ctx);
+        // createErrorResponse shape: { error: { code, message, ... } }
+        const errCode = (result as { error?: { code?: string } } | undefined)
+          ?.error?.code;
+        if (errCode && errCode !== "SESSION_NOT_FOUND") {
+          span.setStatus({ code: SpanStatusCode.ERROR, message: errCode });
+        }
+        return result;
+      },
     );
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/server/routes/memoryRoutes.ts` around lines 19 - 36,
tracedMemoryHandler currently calls withSpan with a callback that ignores the
span so returned error-response envelopes (e.g.
createErrorResponse/MEMORY_ERROR) are recorded as OK; modify tracedMemoryHandler
to receive the active span from withSpan (or accept a callback signature fn(ctx,
span)) and after awaiting fn(ctx) inspect the returned value for your error
envelope shape and, when it represents an error, call span.setStatus({ code:
SpanStatusCode.ERROR, message: <short message or error.code> }) (import
SpanStatusCode from the tracing library) before returning the envelope so
non-thrown server errors propagate ERROR status to tracing.
src/lib/rag/retrieval/vectorQueryTool.ts-124-131 (1)

124-131: ⚠️ Potential issue | 🟡 Minor

topK uses two different fallbacks here.

The span records params.topK ?? topK, but the actual query uses params.topK || topK. A request with topK: 0 is traced as 0 and executed with the default, which is both misleading and behaviorally inconsistent.

Suggested fix
-              topK: params.topK || topK,
+              topK: params.topK ?? topK,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/rag/retrieval/vectorQueryTool.ts` around lines 124 - 131, The tracing
and execution use different fallbacks for topK: the span records params.topK ??
topK but the call to store.query uses params.topK || topK, causing params.topK
=== 0 to be misrepresented; change the store.query invocation to use the same
nullish-coalescing fallback (params.topK ?? topK) so both tracing and execution
match (update the topK argument in the store.query call that currently uses
params.topK || topK), leaving other parameters (indexName,
queryVector/queryEmbedding, filter, includeVectors, providerOptions) unchanged.
src/lib/server/routes/openApiRoutes.ts-58-90 (1)

58-90: ⚠️ Potential issue | 🟡 Minor

These OpenAPI spans still drop request correlation.

All three handlers ignore ServerContext, so unlike the other traced routes they cannot attach http.request.id. /openapi.json, /openapi.yaml, and /docs will therefore show up as uncorrelated spans when you're debugging a specific request.

Also applies to: 97-133, 140-177

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/server/routes/openApiRoutes.ts` around lines 58 - 90, The OpenAPI
route handlers (the async handler passed to withSpan) ignore ServerContext so
they don't attach per-request correlation (http.request.id); update each handler
(the handler functions that call withSpan in this file for /openapi.json,
/openapi.yaml, and /docs) to accept a ServerContext parameter (e.g., ctx or
serverCtx), extract the request correlation id or trace info from it (e.g.,
ctx.request.id or the request trace header) and either pass it into withSpan as
an attribute or set it on the created span (span.setAttribute("http.request.id",
...)); ensure you still call getRoutes, construct OpenAPIGenerator, and return
generator.generate() but now include the request id on the span (and if your
tracing helper supports a parent context, propagate ctx's trace context into
withSpan using tracers.http).
src/lib/providers/googleAiStudio.ts-1045-1064 (1)

1045-1064: ⚠️ Potential issue | 🟡 Minor

Stream generation:end sends content: "".

Because tokens are pushed through channel as they arrive, nothing accumulates an aggregate string here, so Langfuse's GENERATION observation will always have empty output on the native Gemini 3 stream path. Consider accumulating the streamed text (or at least the last stepText) and passing it as content so Pipeline B has a meaningful response body.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/providers/googleAiStudio.ts` around lines 1045 - 1064, The stream
emitter currently sends an empty content in
nativeStreamEmitter.emit("generation:end"); fix by accumulating the streamed
pieces into a variable (e.g., accumulatedOutput or finalText) as tokens are
pushed to channel (the same place where stepText is produced) and update that
variable with each chunk (or at least set it to the latest stepText). Then use
that accumulatedOutput when emitting the "generation:end" result.content
(alongside existing fields like modelName, providerName,
hitStepLimitWithoutFinalAnswer) so the final payload contains the
aggregated/last streamed text instead of "".
docs/telemetry-gaps-audit.md-460-462 (1)

460-462: ⚠️ Potential issue | 🟡 Minor

Update the verifier path.

Line 462 points to test/telemetry-gaps-verification.test.ts, but this PR adds test/telemetry-gaps-verification.ts. The audit currently sends readers to a non-existent file.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/telemetry-gaps-audit.md` around lines 460 - 462, The documentation
points to the wrong verifier filename; update the reference in the "Verification
Plan" section from "test/telemetry-gaps-verification.test.ts" to the actual file
added "test/telemetry-gaps-verification.ts" so readers are directed to the
existing verifier, ensuring the link/text matches the new test filename.
docs/telemetry-fix-execution-plan.md-5-5 (1)

5-5: ⚠️ Potential issue | 🟡 Minor

Fix the scheduling math in the plan.

Line 5 says the peak parallelism is 6 agents in Phase 5, but Line 181 caps Phase 5 at 5 and Line 195 puts the 6-agent peak in Phase 6. Line 232 also says there are 9 sequential phases, while the sequence listed there contains substantially more stage hops. This makes the execution plan internally inconsistent.

Also applies to: 181-181, 195-195, 231-232

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/telemetry-fix-execution-plan.md` at line 5, The document's execution
plan has inconsistent scheduling numbers: update the "Estimated parallelism"
statement and all phase descriptions so Phase 5 peak concurrency matches the
actual cap (ensure Phase 5 is consistently capped at 5 agents if that is the
intended limit, or change the cap elsewhere to 6 if the intent is a 6-agent
peak), move the 6-agent peak to the correct phase (Phase 6) if that is the true
peak, and reconcile the total count of phases so the line that currently states
"9 sequential phases" matches the actual listed sequence; search for the strings
"Estimated parallelism", "Phase 5", "Phase 6", and "9 sequential phases" and
make the numbers consistent across the whole document.
docs/telemetry-fix-completion-report.md-95-120 (1)

95-120: ⚠️ Potential issue | 🟡 Minor

Correct the modified-file count.

Line 95 says there are 17 modified source files, but the list below contains 23 entries. The report should either update the count or trim the list so the summary stays trustworthy.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/telemetry-fix-completion-report.md` around lines 95 - 120, The summary
header "### Source files modified (17)" is incorrect compared to the following
list of 23 entries; update that header to the correct count "23" (or remove six
entries if you intended to keep 17) so the summary matches the list—look for the
header line "### Source files modified (17)" in
docs/telemetry-fix-completion-report.md and change the numeral to "23" (or trim
the excess entries such as any of the listed files like
src/lib/providers/anthropic.ts, src/lib/providers/openAI.ts,
src/lib/providers/googleAiStudio.ts, src/lib/providers/googleVertex.ts,
src/lib/providers/amazonBedrock.ts,
src/lib/observability/utils/spanSerializer.ts if you choose to reduce to 17).
test/telemetry-gaps-verification.ts-1004-1005 (1)

1004-1005: ⚠️ Potential issue | 🟡 Minor

Use an explicit fallback for indexOf() here.

Line 1005 is broken because indexOf() returns -1, which is truthy in JavaScript. When the first indexOf("execute(") returns -1, the || operator doesn't evaluate the second condition—it just passes through -1. This breaks the intended fallback logic, causing the T8 telemetry gap check to incorrectly return null even when execute = patterns exist in the source.

Suggested fix
-        const altIdx =
-          source.indexOf("execute(") || source.indexOf("execute =");
+        const executeCallIdx = source.indexOf("execute(");
+        const executeAssignIdx = source.indexOf("execute =");
+        const altIdx =
+          executeCallIdx !== -1 ? executeCallIdx : executeAssignIdx;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/telemetry-gaps-verification.ts` around lines 1004 - 1005, The fallback
for finding the "execute" location is wrong because using || with indexOf can
propagate -1; update the logic around altIdx so you first call
source.indexOf("execute(") into a variable (altIdx) and if altIdx === -1 then
set altIdx = source.indexOf("execute ="); ensure you treat -1 as "not found"
rather than relying on truthiness so altIdx correctly falls back to the second
search when the first returns -1.
src/lib/workflow/core/ensembleExecutor.ts-148-150 (1)

148-150: ⚠️ Potential issue | 🟡 Minor

Propagate degraded ensemble outcomes to the OTel span, not just counts.

Both wrappers now always complete as successful traces unless an exception is thrown. That means minResponses failures and early-stop group failures still look green in OTel/Langfuse even though these functions return errors/partial-failure results. Set the span status before returning when the execution outcome is degraded.

Also applies to: 499-501

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/workflow/core/ensembleExecutor.ts` around lines 148 - 150, The span
currently only records counts
(otelSpan.setAttribute("workflow.success_count"..., "workflow.failure_count",
"workflow.total_time_ms") ) but never updates span status for degraded outcomes;
before returning from the ensemble execution (where successCount, failureCount,
minResponses and any returned errors/partial results are known) set
otelSpan.setStatus({ code: SpanStatusCode.ERROR, message: "degraded: <reason>"
}) for degraded outcomes (e.g., successCount < minResponses, early-stop group
failures, or non-empty errors) and otelSpan.setStatus({ code: SpanStatusCode.OK
}) for fully successful runs; apply the same fix to the other
otelSpan.setAttribute block later in the file (the second occurrence) so traces
reflect partial failures instead of always appearing successful.
src/lib/auth/tokenStore.ts-375-382 (1)

375-382: ⚠️ Potential issue | 🟡 Minor

Deduped refresh waiters still miss the new auth outcome attributes.

The new span fields are only written on the fast-path and on the caller that performs the refresh. Callers that hit the existing inFlightRefreshes branch return without ever setting auth.expired / auth.refreshed, so the hot concurrent path still produces incomplete telemetry.

Suggested fix
     const existing = this.inFlightRefreshes.get(provider);
     if (existing) {
       logger.debug("Awaiting in-flight refresh for provider", { provider });
+      span.setAttribute("auth.expired", true);
       const result = await existing;
+      span.setAttribute("auth.refreshed", true);
       return result.accessToken;
     }

Also applies to: 399-413, 512-513

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/auth/tokenStore.ts` around lines 375 - 382, The issue is that callers
who hit the inFlightRefreshes branch return without setting span attributes like
"auth.expired" and "auth.refreshed", so add setting of those attributes for all
code paths: in _getValidTokenImpl (the function invoked by withSpan) ensure that
whenever a token result is resolved—whether from the fast-path, the caller
performing refresh (refreshToken / performRefresh logic), or a waiter that
awaited an existing inFlightRefreshes promise—you call span.setAttribute for the
same auth attributes before returning; update the branch that reads from
inFlightRefreshes to extract the refresh outcome and set
span.setAttribute("auth.expired", ...)/setAttribute("auth.refreshed", ...) (and
any other auth.* attributes used elsewhere) so telemetry is populated for
deduped waiters as well, and apply the same change to the equivalent paths
referenced around the other ranges (the refresh handling in the token refresh
functions at the other locations).
src/lib/auth/sessionManager.ts-418-427 (1)

418-427: ⚠️ Potential issue | 🟡 Minor

Record the resolved storage backend here, not just the requested config.

createStorage() can fall back from "redis" to MemorySessionStorage, but this span still reports auth.storage from this.config.storage. That makes the new auth telemetry wrong on the fallback path.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/auth/sessionManager.ts` around lines 418 - 427, The span currently
logs "auth.storage" using this.config.storage which may differ from the actual
backend returned by createStorage(); before creating the span for
neurolink.auth.session.create, resolve the real storage backend (e.g., call or
access the existing resolved storage from createStorage()/this.storage or the
method that performs fallback) and set the span attribute "auth.storage" to that
resolved value instead of this.config.storage; ensure the change is applied
around the withSpan invocation that wraps this._createSession so the span
reflects the actual backend used.
🧹 Nitpick comments (10)
src/lib/providers/amazonSagemaker.ts (1)

139-152: Redundant try/catch wrapper.

The inner try { throw ... } catch (error) { throw this.handleProviderError(error); } can be simplified to a direct throw this.handleProviderError(new SageMakerError(...)), since the only thing thrown inside the try is the SageMakerError constructed on the same line. This also makes it clearer that handleProviderError is always invoked on this sentinel error.

♻️ Proposed simplification
-      async () => {
-        try {
-          // For now, throw an error indicating this is not yet implemented
-          throw new SageMakerError(
-            "SageMaker streaming not yet fully implemented. Coming in next phase.",
-            {
-              code: "MODEL_ERROR",
-              statusCode: 501,
-              endpoint: this.modelConfig.endpointName,
-            },
-          );
-        } catch (error) {
-          throw this.handleProviderError(error);
-        }
-      },
+      async () => {
+        // Not yet implemented — surface a typed provider error through the span.
+        throw this.handleProviderError(
+          new SageMakerError(
+            "SageMaker streaming not yet fully implemented. Coming in next phase.",
+            {
+              code: "MODEL_ERROR",
+              statusCode: 501,
+              endpoint: this.modelConfig.endpointName,
+            },
+          ),
+        );
+      },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/providers/amazonSagemaker.ts` around lines 139 - 152, Remove the
redundant try/catch and directly throw the handled SageMakerError: replace the
try { throw new SageMakerError(...) } catch (error) { throw
this.handleProviderError(error) } pattern with a single throw
this.handleProviderError(new SageMakerError(...)); ensure you construct the
SageMakerError with the same message, code/statusCode and endpoint
(this.modelConfig.endpointName) so handleProviderError is invoked
deterministically on that sentinel error.
src/lib/rag/chunkers/BaseChunker.ts (1)

92-96: Redundant span attributes — content_length and content_chars are identical.

Both are set to content.length. Drop one (or rename one to represent a distinct dimension, e.g., byte length vs char length) to avoid duplicated cardinality in traces.

♻️ Suggested diff
         attributes: {
           "rag.chunker.strategy": this.strategy,
           "rag.chunker.content_length": content.length,
-          "rag.chunker.content_chars": content.length,
         },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/rag/chunkers/BaseChunker.ts` around lines 92 - 96, In BaseChunker.ts
update the span attributes to avoid duplicate cardinality: remove either
"rag.chunker.content_length" or "rag.chunker.content_chars" (whichever is unused
elsewhere) or change one to represent a distinct metric (e.g., byte length) by
computing Buffer.byteLength(content) and setting that attribute instead; ensure
the updated attributes object still includes "rag.chunker.strategy" and a single
clear content-size attribute so traces no longer contain identical values for
both keys.
src/lib/core/modules/StreamHandler.ts (1)

122-124: Sentinel chunk carries metadata but the generator return type doesn't declare it.

createTextStream is typed as AsyncGenerator<{ content: string }>, so downstream consumers in Pipeline B that need to detect noOutput/errorType to set SpanStatus.WARNING will have to cast or rely on as any to see those fields. Consider widening the return type to include the optional metadata so the sentinel contract is part of the public type surface:

♻️ Suggested type widening
-  createTextStream(result: {
-    textStream: AsyncIterable<string>;
-  }): AsyncGenerator<{ content: string }> {
+  createTextStream(result: {
+    textStream: AsyncIterable<string>;
+  }): AsyncGenerator<{
+    content: string;
+    metadata?: { noOutput?: boolean; errorType?: string };
+  }> {

Also applies to: 159-167

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/core/modules/StreamHandler.ts` around lines 122 - 124, The generator
return type for createTextStream is too narrow (AsyncGenerator<{ content: string
}>) and omits the sentinel metadata, so widen the return type to include an
optional metadata object (e.g., metadata?: { noOutput?: boolean; errorType?:
string; [key: string]: any }) so downstream consumers can read
noOutput/errorType without casting; update createTextStream’s AsyncGenerator
generic accordingly and apply the same widening to the other generator at the
159-167 block (the related stream factory) so the sentinel contract is part of
the public type surface.
src/lib/client/sseClient.ts (1)

109-128: Recursive reconnect produces nested spans.

attemptReconnect() (line 698) calls this.stream(...) again, which executes from inside the parent span's active context. Each reconnect therefore nests a new neurolink.client.sse.stream span under the previous one. With long-lived sessions and multiple reconnects this yields a deep span tree in Langfuse.

Consider either invoking _streamInternal(...) directly from attemptReconnect, or starting the reconnect span as a sibling (e.g., context.with(ROOT_CONTEXT, ...)) so reconnects are peers rather than children.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/client/sseClient.ts` around lines 109 - 128, The current
implementation of stream (wrapping _streamInternal in withClientSpan) causes
attemptReconnect() to call this.stream(...) and create nested
"neurolink.client.sse.stream" spans; update the reconnection path so reconnects
are siblings instead of children by either having attemptReconnect call
this._streamInternal(path, options, callbacks) directly (bypassing
withClientSpan) or by wrapping the reconnection call in a root context (e.g.,
use context.with(ROOT_CONTEXT, () => this.stream(...))) so each reconnect starts
a peer span; locate attemptReconnect, stream, and _streamInternal in
sseClient.ts and apply one of these fixes consistently.
src/lib/telemetry/traceContext.ts (1)

16-21: Prefer the canonical isSpanContextValid over a hardcoded zero-trace string.

@opentelemetry/api exposes isSpanContextValid(spanContext) which checks both traceId and spanId validity per the OTel spec. The current check misses the case where traceId is non-zero but spanId is the invalid all-zero value — Pipeline B spans would then be linked with parentSpanId: "0000000000000000".

♻️ Suggested change
-import { trace, context } from "@opentelemetry/api";
+import { trace, context, isSpanContextValid } from "@opentelemetry/api";
@@
   const activeSpan = trace.getSpan(context.active());
   if (!activeSpan) {
     return {};
   }
   const ctx = activeSpan.spanContext();
-  // Invalid trace IDs are all zeros — don't use those
-  if (!ctx.traceId || ctx.traceId === "00000000000000000000000000000000") {
+  if (!isSpanContextValid(ctx)) {
     return {};
   }
   return { traceId: ctx.traceId, parentSpanId: ctx.spanId };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/telemetry/traceContext.ts` around lines 16 - 21, Replace the manual
zero-check on activeSpan.spanContext() with the canonical isSpanContextValid
check from `@opentelemetry/api`: import isSpanContextValid, call
isSpanContextValid(ctx) on the ctx returned by activeSpan.spanContext(), and
only return { traceId: ctx.traceId, parentSpanId: ctx.spanId } when
isSpanContextValid returns true; otherwise return {} — this ensures both traceId
and spanId are validated (preventing parentSpanId from being the all-zero span).
src/lib/providers/azureOpenai.ts (1)

182-190: Duplicated inline type cast for toolResults across providers.

The same as Array<{ toolName: string; output?: unknown; result?: unknown; error?: string }> cast is repeated in 7 provider onStepFinish handlers in this PR. Consider moving the type handling into emitToolEndFromStepFinish's parameter type (e.g., accept the AI SDK's native step-finish toolResults type, or unknown[] and narrow internally) so each call site reduces to emitToolEndFromStepFinish(this.neurolink?.getEventEmitter(), event.toolResults).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/providers/azureOpenai.ts` around lines 182 - 190, The repeated inline
cast of event.toolResults should be removed by changing
emitToolEndFromStepFinish’s parameter type to accept the provider SDK’s native
toolResults type (or simply unknown[]), then perform proper internal
narrowing/validation inside emitToolEndFromStepFinish; update calls like
emitToolEndFromStepFinish(this.neurolink?.getEventEmitter(), event.toolResults)
(remove the as Array<...> cast) and keep any runtime guards inside
emitToolEndFromStepFinish to handle optional neurolink and shape-check tool
result entries (referencing emitToolEndFromStepFinish,
neurolink?.getEventEmitter(), and event.toolResults).
src/lib/server/routes/toolRoutes.ts (1)

339-358: Minor: GET /tools/:name doesn't mark span ERROR when tool is missing.

The execute handlers flip tool.found=false + setStatus(ERROR) for the same condition, but the GET handler returns createErrorResponse(...) silently. Consider mirroring the ERROR status for trace-level parity.

-            async () => {
+            async (span) => {
               const tools = await ctx.toolRegistry.listTools();
               const tool = tools.find((t) => t.name === name);

               if (!tool) {
+                span.setAttribute("tool.found", false);
+                span.setStatus({
+                  code: SpanStatusCode.ERROR,
+                  message: `Tool '${name}' not found`,
+                });
                 return createErrorResponse(
                   "TOOL_NOT_FOUND",
                   `Tool '${name}' not found`,
                   undefined,
                   ctx.requestId,
                 );
               }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/server/routes/toolRoutes.ts` around lines 339 - 358, The GET
/tools/:name handler returns createErrorResponse when a tool isn't found but
does not mark the trace span as error; update the async handler (the function
that calls ctx.toolRegistry.listTools and returns createErrorResponse) to mirror
the execute handlers by calling ctx.span.setAttribute('tool.found', false) and
ctx.span.setStatus({ code: SpanStatusCode.ERROR }) (or your project's
equivalent) immediately before returning createErrorResponse so the span/error
state matches other handlers.
docs/telemetry-baseline-proof.txt (1)

98-114: Optional: label baseline artifact explicitly to avoid confusion with the post-fix report.

The TYPECHECK/LINT sections intentionally capture a pre-fix failure state (ERR_MODULE_NOT_FOUND, prettier warnings, ELIFECYCLE exit 1), which can be mistaken for a regression when diffed against the completion report. A one-line header like # Pre-fix baseline — captured before telemetry remediation. Failures below are expected. at the top would make the intent unambiguous.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/telemetry-baseline-proof.txt` around lines 98 - 114, Add an explicit
one-line header at the top of the document clarifying this is the pre-fix
baseline (for example: "Pre-fix baseline — captured before telemetry
remediation. Failures below are expected.") so readers won't confuse the
TYPECHECK/LINT block (the "TYPECHECK/LINT" sections showing
ERR_MODULE_NOT_FOUND, prettier warnings, ELIFECYCLE) with a post-fix regression;
update the existing docs/telemetry-baseline-proof.txt content to prepend that
single clarifying line.
src/lib/utils/toolEndEmitter.ts (1)

82-91: Optional: allow callers to pass a per-result responseTime instead of hardcoding 0.

Pipeline B will currently report zero duration for every AI-SDK-driven tool call emitted through this helper, which loses a useful metric. The AI SDK doesn't expose per-tool timing in onStepFinish, but callers that measure elapsed time around the SDK call could thread it through.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/utils/toolEndEmitter.ts` around lines 82 - 91, The emitter currently
hardcodes responseTime: 0 when calling createToolEventPayload in
toolEndEmitter.ts; change the relevant function (the helper that calls
emitter.emit with tr.toolName and createToolEventPayload) to accept an optional
responseTime parameter (defaulting to 0) and pass that value instead of 0 into
createToolEventPayload; update any callers (e.g., places using the helper from
onStepFinish or other SDK wrappers) to forward measured durations when available
so per-call timing is preserved.
docs/telemetry-fix-plan.md (1)

1-6: Add a “status” banner so this plan isn’t mistaken for pending work.

Given this PR appears to have implemented the phases, add a short header note like “Historical execution plan for PR #962; see completion report for final state.” This will prevent operational confusion.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/telemetry-fix-plan.md` around lines 1 - 6, Add a short status banner at
the top of the NeuroLink Telemetry Fix Plan document to mark it as
historical/complete (for example: "Historical execution plan for PR `#962`; see
completion report for final state.") so readers don’t treat it as pending work;
insert this one-line header directly under the existing title "NeuroLink
Telemetry Fix Plan" (or at the very top) and ensure it is clearly labeled as a
historical/completed plan.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 81bd77e5-328e-4a61-ab9c-013057e2c90e

📥 Commits

Reviewing files that changed from the base of the PR and between 49f56cd and ce38e9b.

📒 Files selected for processing (69)
  • docs/telemetry-after-proof.txt
  • docs/telemetry-baseline-proof.txt
  • docs/telemetry-fix-completion-report.md
  • docs/telemetry-fix-diff.txt
  • docs/telemetry-fix-execution-plan.md
  • docs/telemetry-fix-plan.md
  • docs/telemetry-gaps-audit.md
  • src/cli/loop/session.ts
  • src/lib/auth/anthropicOAuth.ts
  • src/lib/auth/sessionManager.ts
  • src/lib/auth/tokenStore.ts
  • src/lib/client/httpClient.ts
  • src/lib/client/sseClient.ts
  • src/lib/client/streamingClient.ts
  • src/lib/client/wsClient.ts
  • src/lib/context/budgetChecker.ts
  • src/lib/context/contextCompactor.ts
  • src/lib/context/summarizationEngine.ts
  • src/lib/core/baseProvider.ts
  • src/lib/core/modules/GenerationHandler.ts
  • src/lib/core/modules/StreamHandler.ts
  • src/lib/core/modules/ToolsManager.ts
  • src/lib/evaluation/ragasEvaluator.ts
  • src/lib/mcp/batching/requestBatcher.ts
  • src/lib/mcp/httpRateLimiter.ts
  • src/lib/mcp/httpRetryHandler.ts
  • src/lib/mcp/mcpClientFactory.ts
  • src/lib/mcp/toolDiscoveryService.ts
  • src/lib/mcp/toolRegistry.ts
  • src/lib/memory/memoryRetrievalTools.ts
  • src/lib/neurolink.ts
  • src/lib/observability/utils/spanSerializer.ts
  • src/lib/processors/base/BaseFileProcessor.ts
  • src/lib/processors/registry/ProcessorRegistry.ts
  • src/lib/providers/amazonBedrock.ts
  • src/lib/providers/amazonSagemaker.ts
  • src/lib/providers/anthropic.ts
  • src/lib/providers/azureOpenai.ts
  • src/lib/providers/googleAiStudio.ts
  • src/lib/providers/googleVertex.ts
  • src/lib/providers/huggingFace.ts
  • src/lib/providers/litellm.ts
  • src/lib/providers/mistral.ts
  • src/lib/providers/ollama.ts
  • src/lib/providers/openAI.ts
  • src/lib/providers/openRouter.ts
  • src/lib/providers/openaiCompatible.ts
  • src/lib/rag/chunkers/BaseChunker.ts
  • src/lib/rag/ragIntegration.ts
  • src/lib/rag/reranker/reranker.ts
  • src/lib/rag/retrieval/vectorQueryTool.ts
  • src/lib/server/middleware/common.ts
  • src/lib/server/routes/agentRoutes.ts
  • src/lib/server/routes/claudeProxyRoutes.ts
  • src/lib/server/routes/healthRoutes.ts
  • src/lib/server/routes/mcpRoutes.ts
  • src/lib/server/routes/memoryRoutes.ts
  • src/lib/server/routes/openApiRoutes.ts
  • src/lib/server/routes/toolRoutes.ts
  • src/lib/services/server/ai/observability/instrumentation.ts
  • src/lib/telemetry/traceContext.ts
  • src/lib/telemetry/tracers.ts
  • src/lib/types/span.ts
  • src/lib/utils/toolEndEmitter.ts
  • src/lib/workflow/core/ensembleExecutor.ts
  • src/lib/workflow/core/judgeScorer.ts
  • src/lib/workflow/core/responseConditioner.ts
  • src/lib/workflow/core/workflowRunner.ts
  • test/telemetry-gaps-verification.ts

Comment thread docs/telemetry-after-proof.txt Outdated
Comment thread docs/telemetry-fix-plan.md Outdated
Comment on lines +861 to +875
onEnd(span: ReadableSpan): void {
// ... existing logic ...

// Propagate error info to Langfuse-consumable attributes
if (span.status.code === SpanStatusCode.ERROR) {
span.attributes["langfuse.level"] = "ERROR";
if (span.status.message) {
span.attributes["langfuse.status_message"] = span.status.message;
}
}
}
```

**Note**: `ReadableSpan` is read-only by spec. `LangfuseSpanProcessor` may or may not read these attributes. Test whether `@langfuse/otel` respects custom attributes for level/statusMessage. If not, this fix has no effect and should be replaced with a custom span processor that wraps `LangfuseSpanProcessor`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

In OpenTelemetry JS, can a SpanProcessor safely mutate ReadableSpan.attributesinsideonEnd? Please cite the official API/docs for ReadableSpanandSpanProcessor.

💡 Result:

No, a SpanProcessor cannot safely mutate ReadableSpan.attributes inside onEnd in OpenTelemetry JS. The official API documentation shows that SpanProcessor.onEnd(span: ReadableSpan): void receives a ReadableSpan [1,20]. The ReadableSpan interface defines attributes as a readonly property (Readonly attributes: Attributes) with no mutation methods like setAttribute [7]. In contrast, onStart receives a mutable Span [1,20], and the experimental onEnding(span: Span): void also receives a mutable Span for modifications before finalization [20,22]. The OpenTelemetry specification confirms onEnd receives a readable span where modification is not allowed, while recent updates (PR #4024 merged 2024) introduced onEnding specifically for safe mutations on end [5,16,22]. Attempting to mutate ReadableSpan.attributes directly (e.g., span.attributes['key'] = value) would violate the readonly contract and is unsafe/undefined behavior, as attributes provides read access only.

Citations:


ReadableSpan mutation in onEnd violates the readonly contract—use onEnding instead.

Per OpenTelemetry JS API documentation, SpanProcessor.onEnd(span: ReadableSpan) receives a read-only span with a readonly attributes property that cannot be safely mutated. The attempt to set span.attributes["..."] = ... would violate this contract and result in undefined behavior.

Instead, use the onEnding(span: Span): void method (experimental, available after PR #4024), which receives a mutable Span and permits attribute mutation before span finalization. Revise the plan to clarify this distinction and update the implementation snippet accordingly.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/telemetry-fix-plan.md` around lines 861 - 875, The plan mutates a
ReadableSpan in onEnd which violates the readonly contract; update to use the
mutable onEnding(span: Span) hook instead. Change any logic in
LangfuseSpanProcessor that currently reads/writes attributes in onEnd to
implement onEnding(span: Span): void and set span.setAttribute or mutate
span.attributes there (referencing the Span type and onEnding method), and keep
onEnd only for read-only post-finalization handling; if onEnding is unavailable,
implement a custom wrapper span processor that intercepts Span before
finalization and augments attributes for Langfuse prior to delegating to
LangfuseSpanProcessor.

Comment thread src/cli/loop/session.ts
Comment thread src/lib/memory/memoryRetrievalTools.ts
Comment thread src/lib/neurolink.ts
Comment thread src/lib/server/routes/claudeProxyRoutes.ts Outdated
Comment thread src/lib/server/routes/mcpRoutes.ts
Comment thread src/lib/workflow/core/judgeScorer.ts
Comment thread src/lib/workflow/core/workflowRunner.ts Outdated
Comment thread src/lib/workflow/core/workflowRunner.ts
@murdore

murdore commented Apr 17, 2026

Copy link
Copy Markdown
Contributor Author

Review Feedback Addressed (Cycle 2)

Changes Made

All 13 comments from CodeRabbit's second review have been analyzed. 11 fixed, 2 addressed as doc-only.

# File Comment Action Status
1 docs/telemetry-after-proof.txt:103 Machine-specific path + ERR_MODULE_NOT_FOUND Sanitized — removed local path and stack trace ✅ Fixed
2 docs/telemetry-fix-plan.md:875 ReadableSpan mutation in onEnd violates readonly Updated doc note to warn against onEnd mutation, recommend onEnding ✅ Fixed (doc)
3 src/cli/loop/session.ts:199 Missing setStatus(ERROR) on CLI turn span Added SpanStatusCode.ERROR + setStatus() in catch block ✅ Fixed
4 src/lib/memory/memoryRetrievalTools.ts:305 ReDoS-prone regex from user input Escaped user input as literal before constructing RegExp ✅ Fixed
5 src/lib/neurolink.ts:760 Pipeline B spans reuse parent spanId (OTel violation) Each Pipeline B span now keeps its own unique spanId; parent's ID stored as parentSpanId ✅ Fixed
6 src/lib/neurolink.ts:3991 step_count always falls back to 1 Changed to read from textResult (raw provider result) instead of flattened generateResult DTO ✅ Fixed
7 src/lib/providers/amazonBedrock.ts:304 Missing failure generation:end on generate() error Added try/catch around conversationLoop — emits generation:end with success: false before rethrowing ✅ Fixed
8 src/lib/providers/googleAiStudio.ts:1077 Native stream error path missing generation:end Added failure generation:end emit in catch block matching success path shape ✅ Fixed
9 src/lib/server/routes/claudeProxyRoutes.ts:4883 Non-array messages throws 500 instead of 400 Changed to typeof body?.model !== "string" || !Array.isArray(body?.messages) ✅ Fixed
10 src/lib/server/routes/mcpRoutes.ts:42 Returned MCP errors look successful in telemetry tracedMcpHandler now inspects returned payload and calls setStatus(ERROR) on error responses ✅ Fixed
11 src/lib/workflow/core/judgeScorer.ts:53 Outer OTel span not marked ERROR on judge failure Added otelSpan.recordException() + otelSpan.setStatus(ERROR) before returning error result ✅ Fixed
12 src/lib/workflow/core/workflowRunner.ts:107 Returned workflow failures look successful Added otelSpan param to callback, set ERROR status on catch path ✅ Fixed
13 src/lib/workflow/core/workflowRunner.ts:691 Async generator span leak on early cancellation Added finally block with spanEnded guard to ensure span is always ended ✅ Fixed

Files Modified

  • docs/telemetry-after-proof.txt — sanitized machine-specific paths
  • docs/telemetry-fix-plan.md — corrected ReadableSpan mutation guidance
  • src/cli/loop/session.ts — added SpanStatusCode.ERROR on turn span
  • src/lib/memory/memoryRetrievalTools.ts — ReDoS prevention via regex escaping
  • src/lib/neurolink.ts — unique Pipeline B spanIds + step_count from textResult
  • src/lib/providers/amazonBedrock.ts — failure generation:end emit + Prettier format fix
  • src/lib/providers/googleAiStudio.ts — failure generation:end on native stream error
  • src/lib/server/routes/claudeProxyRoutes.ts — Array.isArray validation for messages
  • src/lib/server/routes/mcpRoutes.ts — error detection in tracedMcpHandler
  • src/lib/workflow/core/judgeScorer.ts — outer OTel span ERROR status
  • src/lib/workflow/core/workflowRunner.ts — OTel span ERROR status + finally guard

Validation

  • TypeScript: ✅ 0 errors
  • Prettier: ✅ All files formatted
  • ESLint: ✅ 0 errors (13 pre-existing warnings, none from our changes)
  • Full Build: ✅ Build + prepack succeeded
  • Security: ✅ No secrets detected

Requesting Re-review

@coderabbitai — All 13 comments from the second review have been addressed. Please re-review.

@murdore

murdore commented Apr 17, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Apr 17, 2026

Copy link
Copy Markdown
✅ Actions performed

Full review triggered.

@coderabbitai

coderabbitai Bot commented Apr 17, 2026

Copy link
Copy Markdown

@murdore — Thank you for the detailed Cycle 2 summary. The fixes look solid across the board. A few highlights worth noting:

  • ReDoS fix (#4): Escaping user input before RegExp construction in memoryRetrievalTools.ts is the correct approach — good catch and clean fix.
  • Pipeline B spanId uniqueness (#5): This is an important OTel compliance fix. Reusing a parent's spanId as the child's own spanId would cause trace backends (including Langfuse) to silently corrupt the trace graph — glad this was resolved with unique span IDs and proper parentSpanId threading.
  • Generator span leak (#13): The spanEnded guard in the finally block of runWorkflowWithStreaming is the right pattern for async generators where return/throw/cancellation can all trigger the finally path at different times.
  • Bedrock/GeminiAI failure generation:end (#7, #8): Having both success and failure paths emit the event ensures Pipeline B always has a terminal signal — no more orphaned spans on provider errors.
  • MCP error telemetry (#10): Inspecting the returned payload in tracedMcpHandler to detect silent error responses and marking the span ERROR is a good pattern since MCP often returns errors as successful HTTP responses.

Triggering the full re-review now.

✅ Actions performed

Full review triggered.

@github-actions

Copy link
Copy Markdown
Contributor

🤖 AI Review & Build Compliance ✅

Status: AI analysis complete • Build rules validated • Ready for review

📊 View detailed analysis results

🛡️ Analysis Complete

  • ✅ Security scan (vulnerabilities, API keys)
  • ✅ TypeScript safety & code quality
  • ✅ Error handling & best practices
  • ✅ Build rule enforcement validated
  • ✅ Commit format & compliance checks

📋 Ready for Merge When

  • All CI checks passing
  • Manual review approved
  • Any AI-flagged issues resolved

🤖 AI analysis complete - check individual code comments for specific feedback

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 15

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (8)
src/lib/auth/tokenStore.ts (1)

435-440: ⚠️ Potential issue | 🟡 Minor

Span loses auth.refreshed attribute on the in-flight dedup path.

When a concurrent caller awaits an existing in-flight refresh, the token is effectively refreshed for this call, but the span never records auth.refreshed. Only the leading caller sets it on line 512. Set the attribute here too so Langfuse shows consistent refresh accounting across concurrent requests.

🛠️ Suggested change
     const existing = this.inFlightRefreshes.get(provider);
     if (existing) {
       logger.debug("Awaiting in-flight refresh for provider", { provider });
       const result = await existing;
+      span.setAttribute("auth.refreshed", true);
       return result.accessToken;
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/auth/tokenStore.ts` around lines 435 - 440, Concurrent callers that
await an existing in-flight refresh via this.inFlightRefreshes.get(provider)
never set the tracing attribute auth.refreshed, so spans for those callers miss
the refresh marker; after awaiting the existing Promise (the variable existing
-> result), set the same span attribute used by the leading caller (e.g.,
span.setAttribute('auth.refreshed', true) or the equivalent tracing helper used
elsewhere in this file) before returning result.accessToken so concurrent paths
record the refresh consistently.
src/lib/core/modules/StreamHandler.ts (1)

122-184: ⚠️ Potential issue | 🟠 Major

Return type hides the sentinel metadata; Pipeline B cannot access it type-safely.

createTextStream declares AsyncGenerator<{ content: string }>, but lines 161–167 emit { content: "", metadata: { noOutput, errorType } } when the stream produces no output. Downstream code iterates the stream via for await (const chunk of result.stream) and sees only chunk.content, making the sentinel metadata inaccessible despite being emitted. The type contract must be widened so Pipeline B can read the noOutput flag to set appropriate span status.

Widen the generator chunk type to expose optional metadata
-  createTextStream(result: {
-    textStream: AsyncIterable<string>;
-  }): AsyncGenerator<{ content: string }> {
+  createTextStream(result: {
+    textStream: AsyncIterable<string>;
+  }): AsyncGenerator<{
+    content: string;
+    metadata?: { noOutput?: boolean; errorType?: string };
+  }> {

If a shared stream-chunk type already exists in src/lib/types/, prefer reusing or extending it instead of the inline shape.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/core/modules/StreamHandler.ts` around lines 122 - 184,
createTextStream currently types its return as AsyncGenerator<{ content: string
}>, but it yields a sentinel chunk with metadata (noOutput, errorType) which
downstream Pipeline B cannot access type-safely; widen the generator item type
(or reuse/extend a shared stream-chunk type in src/lib/types) to include an
optional metadata property (e.g., metadata?: { noOutput?: boolean; errorType?:
string }) so consumers iterating the stream (for await ... of result.stream) can
read chunk.metadata.noOutput; update the createTextStream signature and any
affected callers/types to the new shared/extended chunk type.
src/lib/types/span.ts (1)

50-55: ⚠️ Potential issue | 🟠 Major

Fix WARNING status handling across observability consumers.

The SpanStatus.WARNING enum extension introduces real bugs: otelBridge maps WARNING to OTel OK status (line 149), metricsAggregator counts WARNING as success (line 102), posthogExporter and laminarExporter default WARNING to "unset" (no case in switch), datadogExporter loses the signal (maps to "info"), and continuous-test-suite-workflow assertions exclude WARNING entirely. While spanSerializer correctly maps WARNING → "WARNING" in Langfuse format, downstream consumers must handle all three statuses:

  • otelBridge.ts:149 — Update ternary to handle WARNING (map to OTel ERROR or introduce new OTel code)
  • metricsAggregator.ts:102, 493 — Use enum comparisons instead of hardcoded 2 or 1; define WARNING aggregation logic
  • posthogExporter.ts, laminarExporter.ts — Add case SpanStatus.WARNING: handlers
  • datadogExporter.ts:186 — Map WARNING to appropriate Datadog status
  • samplers.ts:160 — Consider if WARNING should be sampled with error-priority
  • continuous-test-suite-workflow.ts:1874 — Include SpanStatus.WARNING in final status assertions
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/types/span.ts` around lines 50 - 55, The new SpanStatus.WARNING enum
value is not handled consistently across consumers; update each consumer to
explicitly handle SpanStatus.WARNING rather than relying on numeric literals or
default branches: in otelBridge (function handling OTel mapping) change the
ternary at otelBridge.ts:149 to explicitly check for SpanStatus.WARNING and map
it to the correct OTel status (or introduce a new mapping), in metricsAggregator
(symbols referencing aggregation logic at metricsAggregator.ts:102 and :493)
replace hardcoded numeric checks (e.g., `=== 1`/`=== 2`) with enum comparisons
against SpanStatus.OK/ERROR/WARNING and implement aggregation rules for WARNING,
add a `case SpanStatus.WARNING:` branch in both posthogExporter and
laminarExporter switch handlers, update datadogExporter mapping (around the
handler at datadogExporter.ts:186) to map WARNING to the appropriate Datadog
status, review samplers (samplers.ts around line 160) to decide whether WARNING
should be sampled with error-priority and adjust logic accordingly, and include
SpanStatus.WARNING in the final status assertions in
continuous-test-suite-workflow (around 1874) so tests account for WARNING
outcomes.
src/lib/workflow/core/ensembleExecutor.ts (1)

139-158: ⚠️ Potential issue | 🟡 Minor

Outer OTel span stays OK when all ensemble models fail.

The Pipeline B SpanSerializer span is set to SpanStatus.ERROR when successCount === 0, but the outer OTel span from withSpan(...) only receives success/failure count attributes and no setStatus({ code: ERROR }). Pipeline A/B will disagree in Langfuse for fully failed ensembles. Consider mirroring the ERROR status onto otelSpan here (and similarly at the tail of executeModelGroupsInner on lines 499–501 when totalSuccessCount === 0).

🛠️ Suggested fix
+  if (successCount === 0) {
+    otelSpan.setStatus({
+      code: (await import("@opentelemetry/api")).SpanStatusCode.ERROR,
+      message: "No successful model responses",
+    });
+  }
   otelSpan.setAttribute("workflow.success_count", successCount);
   otelSpan.setAttribute("workflow.failure_count", failureCount);
   otelSpan.setAttribute("workflow.total_time_ms", totalTime);

(Prefer a static top-level import { SpanStatusCode } from "@opentelemetry/api" instead of the inline dynamic import.)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/workflow/core/ensembleExecutor.ts` around lines 139 - 158, The outer
OpenTelemetry span (otelSpan) is not marked ERROR when all ensemble models fail,
causing mismatch with the SpanSerializer span; update the end-of-ensemble logic
(around SpanSerializer.endSpan and where successCount is computed) to call
otelSpan.setStatus({ code: SpanStatusCode.ERROR }) when successCount === 0 (and
mirror the same change at the end of executeModelGroupsInner when
totalSuccessCount === 0), and add a top-level import for SpanStatusCode from
"@opentelemetry/api".
src/lib/providers/amazonBedrock.ts (2)

1627-1697: ⚠️ Potential issue | 🟡 Minor

analyticsPromise can hang forever if the returned stream is never consumed.

resolveAnalytics only fires from the async iterator's finally. If a caller destructures analytics but abandons stream (early error handling, cancelled request, conditional flow), awaiting analytics will never settle — and no generation:end is emitted either.

Consider either (a) resolving analyticsPromise from the outer streamingConversationLoop scope once the ReadableStream closes/errors (independent of iteration), or (b) attaching a safety queueMicrotask/timeout that resolves with a best-effort analytics snapshot if the iterable is garbage-collected without being consumed. Low-impact for happy-path consumers, but worth tightening because analyticsPromise is exposed on StreamResult.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/providers/amazonBedrock.ts` around lines 1627 - 1697,
analyticsPromise can hang because resolveAnalytics is only called inside
wrappedStreamIterable's finally which never runs if the caller never iterates
stream; update the code so resolveAnalytics is guaranteed to settle regardless
of iteration by adding an outer safety resolver: after creating
analyticsPromise/resolveAnalytics, schedule a fallback resolution via
queueMicrotask (or a short setTimeout) that checks a consumed flag and, if still
false, calls resolveAnalytics(createAnalytics(...)) with best-effort
aggregatedUsage and streamingMode true; also mark the flag as true inside
wrappedStreamIterable's async iterator before yielding (or in its finally) so
the fallback no-ops when the stream is consumed, and keep emitting
generation:end via streamEmitter from both the iterator finally and the fallback
path to preserve telemetry.

1229-1347: ⚠️ Potential issue | 🟠 Major

executeStream rethrows without emitting a failure generation:end on non-permission errors (and on the "null result" fallback throw).

Two paths in this catch block skip Pipeline B entirely:

  1. Non-permission error (Line 1338-1347): when streamingConversationLoop throws synchronously (e.g. prepareStreamCommand failure, bedrockClient.send timeout, non-AccessDeniedException error), the wrappedStreamIterable is never created, its finally never runs, and the catch just ends the span and rethrows. No generation:end is emitted, so Langfuse has no terminal event for the failed stream.
  2. Fallback null result (Line 1275-1283): when generate() returns null, we throw without emitting either — generate() did not emit a failure event in this "success-but-null" case, and nothing downstream will.

Mirror the stream-path finally here (and the generate path you already added) so Pipeline B observes failed streams.

🛠️ Suggested fix
+          const startTime = Date.now();
           logger.debug(
             "🟢 [TRACE] executeStream TRY block - about to call streamingConversationLoop",
           );
@@
             if (!generateResult) {
+              this.neurolink?.getEventEmitter()?.emit("generation:end", {
+                provider: this.providerName,
+                responseTime: Date.now() - startTime,
+                timestamp: Date.now(),
+                result: {
+                  content: "",
+                  usage: { input: 0, output: 0, total: 0 },
+                  model: this.modelName || this.getDefaultModel(),
+                  provider: this.providerName,
+                  finishReason: "error",
+                },
+                success: false,
+                error: "Generate method returned null result",
+              });
               streamSpan.setStatus({
                 code: SpanStatusCode.ERROR,
                 message: "Generate method returned null result",
               });
@@
         } catch (error: unknown) {
@@
           // Re-throw non-permission errors
+          this.neurolink?.getEventEmitter()?.emit("generation:end", {
+            provider: this.providerName,
+            responseTime: Date.now() - startTime,
+            timestamp: Date.now(),
+            result: {
+              content: "",
+              usage: { input: 0, output: 0, total: 0 },
+              model: this.modelName || this.getDefaultModel(),
+              provider: this.providerName,
+              finishReason: "error",
+            },
+            success: false,
+            error: errorObj instanceof Error ? errorObj.message : String(errorObj),
+          });
           streamSpan.setStatus({

Based on learnings "Native providers (amazonBedrock.ts, …) emit their own generation:end events WITHOUT this flag. … allowing native provider events through to Pipeline B."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/providers/amazonBedrock.ts` around lines 1229 - 1347, The catch in
executeStream currently rethrows non-permission errors and the "generate
returned null" error without emitting the Pipeline B terminal event; update
executeStream (the catch block around streamingConversationLoop and the
null-result branch after calling this.generate) to mirror the streaming path's
wrappedStreamIterable finally: before rethrowing, emit a generation:end (or
equivalent) event including failure metadata/reason and ensure streamSpan
records the error and ends, so Pipeline B receives a terminal event for both
non-permission errors and the fallback-null case (refer to the
wrappedStreamIterable finally logic, streamingConversationLoop,
streamSpan.setStatus/recordException/end, and the generate null-result branch).
src/lib/neurolink.ts (2)

3568-3584: ⚠️ Potential issue | 🟠 Major

Record the generate exception on the OTel span.

This catch block sets ERROR status but never calls generateSpan.recordException(error). Failed generate() traces will miss the exception event even though the stream path records it.

Suggested fix
     } catch (error) {
       generateSpan.setStatus({
         code: SpanStatusCode.ERROR,
         message: error instanceof Error ? error.message : String(error),
       });
+      if (error instanceof Error) {
+        generateSpan.recordException(error);
+      }
 
       // G7 fix: Distinguish context overflow errors with dedicated attributes
       if (error instanceof ContextBudgetExceededError) {
         generateSpan.setAttribute("neurolink.error.type", "context_overflow");
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/neurolink.ts` around lines 3568 - 3584, The catch block that handles
failures from generate() currently sets SpanStatusCode.ERROR but doesn't record
the exception; add a call to generateSpan.recordException(error) inside that
catch block (near where generateSpan.setStatus is called) so the exception event
is attached to the OTel span; keep the existing ContextBudgetExceededError
attribute logic (generateSpan.setAttribute(...)) and call recordException before
or immediately after setStatus to ensure the thrown error is captured on the
span.

7665-7705: ⚠️ Potential issue | 🟠 Major

Emit the fallback stream’s real completion metadata.

This completion event hard-codes finishReason: "stop" and chunkCount: 0, and it omits usage. If the fallback stream ends with length/content-filter or produces tokens/chunks, Pipeline B will record the wrong finish reason, chunk count, and cost data.

Suggested fix
-    let fallbackAccumulatedContent = "";
+    let fallbackAccumulatedContent = "";
+    let fallbackChunkCount = 0;
 
     const fallbackProcessedStream = (async function* (self: NeuroLink) {
       try {
         for await (const chunk of fallbackStreamResult.stream) {
+          fallbackChunkCount++;
           if (
             chunk &&
             "content" in chunk &&
             typeof chunk.content === "string"
           ) {
@@
           logger.info(
             `[NeuroLink.handleStreamError] stream() - COMPLETE SUCCESS (fallback)`,
             {
               provider: providerName,
               model: options.model,
               responseTimeMs: Date.now() - startTime,
               contentLength: fallbackAccumulatedContent.length,
             },
           );
 
           // S6 fix: Emit stream:complete after successful fallback so Pipeline B records it
           try {
+            const fallbackFinishReason =
+              fallbackStreamResult.finishReason ?? "stop";
             self.emitter.emit("stream:complete", {
               content: fallbackAccumulatedContent,
               provider: providerName,
               model: options.model || "unknown",
-              finishReason: "stop",
+              finishReason: fallbackFinishReason,
               metadata: {
                 durationMs: Date.now() - startTime,
-                chunkCount: 0,
+                chunkCount: fallbackChunkCount,
                 totalLength: fallbackAccumulatedContent.length,
                 isFallback: true,
-                finishReason: "stop",
+                finishReason: fallbackFinishReason,
+                usage: fallbackStreamResult.usage,
               },
             });
           } catch {
             /* non-blocking */
           }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/neurolink.ts` around lines 7665 - 7705, The stream-complete event
inside the fallbackProcessedStream generator is emitting hard-coded metadata
(finishReason: "stop", chunkCount: 0, no usage) which misreports fallback
results; update the logic in fallbackProcessedStream (used on NeuroLink) to
compute real completion metadata from fallbackStreamResult and the accumulated
chunks: derive finishReason from fallbackStreamResult (or the final
chunk/response object), count emitted chunks as chunkCount, include any
usage/cost info present on fallbackStreamResult or its final chunk, set
totalLength from fallbackAccumulatedContent.length, and include isFallback: true
and accurate durationMs using startTime before emitting
self.emitter.emit("stream:complete") so Pipeline B receives correct
finishReason, chunkCount, and usage data.
♻️ Duplicate comments (11)
src/lib/providers/googleVertex.ts (1)

2200-2225: ⚠️ Potential issue | 🟠 Major

Emit the streamed text in the native Vertex generation:end event.

This manual completion event still sends result.content: "", so downstream telemetry records a successful generation with no output text. Capture the final streamed text buffer and emit that here instead of an empty string.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/providers/googleVertex.ts` around lines 2200 - 2225, The manual
Vertex event emits result.content as an empty string causing telemetry to miss
the actual output; locate where the stream is accumulated (the final streamed
text buffer used during streaming) and replace result.content: "" in the
vertexStreamEmitter.emit("generation:end", ...) call with the accumulated final
text (e.g., the concatenated/trimmed stream buffer variable used elsewhere in
this module). Ensure you reference vertexStreamEmitter and the same final-stream
variable so the emitted payload includes the real output text and still
preserves usage, model, provider, finishReason, and success fields.
docs/telemetry-baseline-proof.txt (1)

98-114: ⚠️ Potential issue | 🟡 Minor

Sanitize the captured proof output before committing it.

This block still contains a machine-specific absolute path and a local module-resolution trace. That makes the artifact non-reproducible and leaks workstation details into the repo.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/telemetry-baseline-proof.txt` around lines 98 - 114, The captured proof
in docs/telemetry-baseline-proof.txt includes a machine-specific absolute path
(e.g. "/Users/sachinsharma/Developer/temp/neurolink-fork/fix/proxy-bug-fixes")
and an internal module-resolution trace ("node:internal/modules/esm/module_job")
which must be sanitized; edit docs/telemetry-baseline-proof.txt to remove or
replace any absolute paths and internal stack traces with nondisclosing
placeholders (e.g. <REPO_ROOT_PATH> and <MODULE_STACK_TRACE>), ensure the output
remains representative, then run Prettier (--write) and re-run the linter to
confirm no formatting or lint warnings remain.
src/lib/server/routes/agentRoutes.ts (1)

57-65: ⚠️ Potential issue | 🟠 Major

Use basePath when setting http.route.

All four spans still hardcode /api/..., so deployments mounted under a different base path will emit the wrong route label and split telemetry. Build the route attribute from basePath instead of a literal.

Also applies to: 139-147, 229-237, 292-301

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/server/routes/agentRoutes.ts` around lines 57 - 65, The spans set a
hardcoded "http.route" (e.g., in the withSpan call for name
"neurolink.http.execute") which breaks telemetry for deployments with non-root
mounts; change the attribute to build the route using the existing basePath
variable plus the endpoint path (e.g., `${basePath}/api/agent/execute`),
normalizing slashes so you don't end up with double or missing slashes; apply
the same change to the other withSpan usages that set "http.route" (the ones for
the other API endpoints in this file) so all spans use basePath-derived routes
instead of literals.
src/lib/rag/reranker/reranker.ts (1)

69-85: ⚠️ Potential issue | 🟠 Major

Don't default reranker weights with ||.

Using || rewrites explicit 0 weights back to the defaults, so callers cannot intentionally disable a factor, and an all-zero config still leaves you normalizing an invalid total. Use ?? here and reject totalWeight <= 0 before dividing.

Also applies to: 227-237

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/rag/reranker/reranker.ts` around lines 69 - 85, The code uses || when
falling back to DEFAULT_WEIGHTS which overrides explicit 0 values and allows an
all-zero sum; change the fallback for weights.semantic, weights.vector, and
weights.position to use the nullish coalescing operator (??) instead of || in
the totalWeight calculation and when building normalizedWeights (references:
weights, DEFAULT_WEIGHTS, normalizedWeights), and add a guard that rejects or
throws (or returns an error) if totalWeight <= 0 before performing the division;
apply the same ?? + validation change to the other occurrence around the
reranker (the block referenced at 227-237).
src/lib/server/routes/toolRoutes.ts (1)

74-76: ⚠️ Potential issue | 🟠 Major

Normalize q, source, and limit before using them.

These values can still arrive as arrays. q.toLowerCase() will throw for requests like ?q=a&q=b, and parseInt(limit, 10) on a non-scalar can silently skew the slice. The tools.search.query attribute has the same problem because it casts ctx.query.q to string before normalization.

Suggested normalization
         handler: async (ctx: ServerContext) =>
           withSpan(
             {
               name: "neurolink.http.tools.search",
               tracer: tracers.http,
               attributes: {
                 "http.route": `${basePath}/tools/search`,
-                "tools.search.query": (ctx.query.q as string) ?? "",
               },
             },
-            async () => {
-              const { q, source, limit } = ctx.query;
+            async (span) => {
+              const rawQ = Array.isArray(ctx.query.q)
+                ? ctx.query.q[0]
+                : ctx.query.q;
+              const rawSource = Array.isArray(ctx.query.source)
+                ? ctx.query.source[0]
+                : ctx.query.source;
+              const rawLimit = Array.isArray(ctx.query.limit)
+                ? ctx.query.limit[0]
+                : ctx.query.limit;
+              const q = typeof rawQ === "string" ? rawQ : "";
+              const source =
+                typeof rawSource === "string" ? rawSource : undefined;
+              const parsedLimit =
+                typeof rawLimit === "string"
+                  ? parseInt(rawLimit, 10)
+                  : Number(rawLimit);
+              const maxResults =
+                Number.isFinite(parsedLimit) && parsedLimit > 0
+                  ? parsedLimit
+                  : 50;
+              span.setAttribute("tools.search.query", q);
               const tools = await ctx.toolRegistry.listTools();

               let filtered = tools;
@@
-              const maxResults = limit ? parseInt(limit, 10) : 50;
               filtered = filtered.slice(0, maxResults);

Also applies to: 80-105

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/server/routes/toolRoutes.ts` around lines 74 - 76, Normalize request
query params before using or logging them: read ctx.query into local scalars
(e.g., const qRaw = ctx.query.q, sourceRaw = ctx.query.source, limitRaw =
ctx.query.limit), coerce arrays to their first element (if Array.isArray(...))
and then set q = String(qRaw || "").toLowerCase(), source = String(sourceRaw ||
"").toLowerCase() (or default), and limit =
Number.isNaN(parseInt(String(limitRaw || "0"), 10)) ? defaultLimit :
parseInt(String(limitRaw), 10). Replace direct uses of (ctx.query.q as string),
ctx.query.source, and ctx.query.limit in the attributes block and in the
handlers (references around tools.search.query and the logic between lines
80-105) with these normalized variables so toLowerCase and parseInt are applied
to scalar strings only.
src/lib/core/baseProvider.ts (1)

261-293: ⚠️ Potential issue | 🔴 Critical

Fallback is still too broad for real-stream failures.

This still retries with fake streaming for any non-terminal executeStream() error when tools are enabled. A 404/500 or provider-side validation failure will now issue a second upstream request, hide the original failure mode, and potentially double spend. The fallback needs a positive “streaming-with-tools unsupported” signal instead of a negative allowlist of terminal errors.

Based on learnings, src/lib/core/baseProvider.ts is the base class all providers extend. Central stream() method merges tools before calling provider-specific executeStream().

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/core/baseProvider.ts` around lines 261 - 293, The current catch in
stream() indiscriminately falls back to executeFakeStreaming() for any
non-terminal error; change it to only fallback when the provider explicitly
signals "streaming-with-tools unsupported" (e.g., by throwing or returning a
well-known sentinel error/object), and otherwise propagate via
handleProviderError(realStreamError). Concretely, update the catch in
baseProvider.stream/executeStream handling to detect a positive signal (unique
symbol/class/name such as StreamingWithToolsUnsupportedError or error.code ===
"STREAMING_TOOLS_UNSUPPORTED") before calling executeFakeStreaming(options,
analysisSchema); for all other errors (including 404/500/validation), call throw
this.handleProviderError(realStreamError) and log the original error and
providerName. Ensure providers are updated to throw that sentinel when they
cannot stream with tools so the fallback is only used when explicitly supported.
src/lib/memory/memoryRetrievalTools.ts (1)

248-255: ⚠️ Potential issue | 🟠 Major

Mark these not-found returns as failed on the outer OTel span.

Session not found and Message not found still return normally here. Because the surrounding withSpan(...) only sees a fulfilled callback, neurolink.memory.retrieve_context is recorded as success even though the tool returned an error payload. Set otelSpan to ERROR before each early return, or throw a typed error and let the wrapper classify it.

Also applies to: 264-270

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/memory/memoryRetrievalTools.ts` around lines 248 - 255, The
early-return paths in memoryRetrievalTools.ts (e.g., the "Session not found"
branch that calls SpanSerializer.endSpan and getMetricsAggregator().recordSpan)
are currently leaving the surrounding withSpan/otel span as successful; update
both the "Session not found" and the "Message not found" branches (the blocks
around the SpanSerializer.endSpan calls) to mark the outer OTel span as ERROR
before returning—either by setting the otelSpan status to ERROR or by throwing a
typed error that the withSpan wrapper will catch—so
neurolink.memory.retrieve_context is recorded as failed when these not-found
cases occur. Ensure you reference and update the
SpanSerializer.endSpan/SpanStatus usage and the
getMetricsAggregator().recordSpan calls in those branches.
src/lib/server/routes/claudeProxyRoutes.ts (2)

4864-4881: ⚠️ Potential issue | 🟠 Major

Reject malformed message entries before calling .map().

Array.isArray(body.messages) still accepts [null], ["x"], and other non-object items. The subsequent m.content access will throw and turn a bad request into a 500 instead of the intended 400. Validate each element shape up front, or coerce/skip non-conforming items before building text.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/server/routes/claudeProxyRoutes.ts` around lines 4864 - 4881, The
code currently assumes each entry in body.messages is an object with a content
field before mapping, which can throw; update the validation around
body/messages to reject or sanitize malformed entries: after confirming
Array.isArray(body?.messages) use a check like body.messages.every(m => m &&
typeof m === "object" && ("content" in m)) and if false return
buildClaudeError(400, "Invalid message entries"); alternatively filter out
nonconforming items before constructing text (e.g., messages.filter(m => m &&
typeof m === "object").map(...)). Ensure you reference the same variables (body,
messages, buildClaudeError, text) and apply this check/filtration immediately
before the mapping that builds text.

3296-3322: ⚠️ Potential issue | 🟠 Major

Sanitize these persisted upstream diagnostics with the shared helper.

These branches still snapshot capped raw response bodies after only removing two header names. If Anthropic echoes prompt text, account metadata, or tokens in an error payload, that data still lands in tracer logs and disk snapshots. Run both headers and body through transformParamsForLogging() before calling the tracer and logProxyBody().

As per coding guidelines Use transformParamsForLogging() to safely strip secrets before logging.

Also applies to: 4245-4273

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/server/routes/claudeProxyRoutes.ts` around lines 3296 - 3322, The
code currently builds retryRespHeaders/safeRetryHeaders and cappedRetryBody then
logs them directly; instead pass both headers and the capped body through the
shared sanitizer transformParamsForLogging() and use the returned sanitized
headers/body for tracer?.logUpstreamResponseHeaders,
tracer?.logUpstreamResponseBody and logProxyBody. Specifically, replace uses of
safeRetryHeaders and cappedRetryBody with the outputs of
transformParamsForLogging (apply it to retryRespHeaders and
retryBody/cappedRetryBody), and ensure contentType and bodySize in logProxyBody
reflect the sanitized payloads where applicable; do the same fix for the other
identical branch referenced.
src/lib/workflow/core/workflowRunner.ts (2)

740-742: ⚠️ Potential issue | 🟠 Major

Early stream cancellation still reports success and drops the serialized workflow span.

If the consumer stops after the preliminary chunk, execution falls through to finally without ever ending or recording the SpanSerializer workflow span. That same path also marks the outer OTel span OK, even though the workflow was aborted before completion. End/record both spans in this path, and use a cancelled/aborted status instead of OK.

Also applies to: 869-905

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/workflow/core/workflowRunner.ts` around lines 740 - 742, The finally
block currently leaves the SpanSerializer workflow span un-ended and sets the
outer OpenTelemetry span to OK when execution is aborted early; update the
finally block in workflowRunner where spanEnded is declared so that if spanEnded
is false you explicitly end/record the SpanSerializer workflow span and set the
outer OTel span status to a cancelled/aborted status (not OK). Concretely: use
the existing spanEnded flag to guard a call to the SpanSerializer end/record
method and call the OTel span setStatus(...) with a CANCELLED/ABORTED code
before closing the outer span, and replicate the same fix for the other finally
region covering the 869-905 area to ensure both spans are always ended and
marked cancelled on early stream cancellation.

689-700: ⚠️ Potential issue | 🟠 Major

startActiveSpan() still won't keep this workflow span active during generator iteration.

The callback only runs while the generator is created. Later next() calls on runWorkflowStreamingInner() execute outside that active context, so child spans created during streaming can still miss this parent unless the iterator methods are bound to the captured OTel context before returning.

Does OpenTelemetry JavaScript `tracer.startActiveSpan()` preserve the active context across later async generator iterations returned from the callback, or should async iterator methods be wrapped with `context.bind()`?
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/workflow/core/workflowRunner.ts` around lines 689 - 700, The span
started with tracers.workflow.startActiveSpan around runWorkflowStreamingInner
only covers generator creation, not subsequent iterations; ensure the async
generator's next/return/throw methods run in the span's context by binding the
returned iterator to the captured OpenTelemetry context (use context.bind or
equivalent) before yielding it; locate the startActiveSpan call that creates
generator and wrap/bind the generator's iterator methods (the variable generator
returned by tracers.workflow.startActiveSpan and the runWorkflowStreamingInner
callback/otelSpan) so child spans created during streaming remain children of
the workflow span.
🧹 Nitpick comments (7)
src/lib/server/routes/memoryRoutes.ts (1)

19-36: Consider setting span ERROR status for error responses.

Since the handlers return createErrorResponse(...) objects (e.g., MEMORY_ERROR, SESSION_NOT_FOUND) rather than throwing, withSpan will never observe a failure and the span will always close as OK. If you want failed requests to be visible in Langfuse/OTel, inspect the handler's return for an error-shaped payload and mark the span accordingly. Non-blocking — flagging for future observability fidelity.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/server/routes/memoryRoutes.ts` around lines 19 - 36,
tracedMemoryHandler currently wraps the handler with withSpan but since handlers
return error-shaped responses rather than throw, spans always close OK; update
tracedMemoryHandler to await the result of fn(ctx) inside the withSpan callback,
detect error-shaped payloads (e.g., the shape produced by createErrorResponse
and constants like MEMORY_ERROR or SESSION_NOT_FOUND) and, when detected, set
the span status to ERROR and/or add an error attribute (use the span API used by
withSpan/tracers.http, e.g., span.setStatus({ code: SpanStatusCode.ERROR,
message: ... }) and span.setAttribute('error', true)) before returning the
result so failed responses are visible in tracing.
src/lib/telemetry/traceContext.ts (1)

12-22: Consider isSpanContextValid instead of a hard-coded all-zero check.

@opentelemetry/api exports isSpanContextValid(spanContext) which validates both traceId and spanId against the OTel spec requirements. The current check only validates traceId for all-zeros but doesn't validate spanId, which could result in an invalid all-zeros spanId being propagated as parentSpanId.

♻️ Suggested refactor
-import { trace, context } from "@opentelemetry/api";
+import { trace, context, isSpanContextValid } from "@opentelemetry/api";
@@
-  const activeSpan = trace.getSpan(context.active());
-  if (!activeSpan) {
-    return {};
-  }
-  const ctx = activeSpan.spanContext();
-  // Invalid trace IDs are all zeros — don't use those
-  if (!ctx.traceId || ctx.traceId === "00000000000000000000000000000000") {
-    return {};
-  }
-  return { traceId: ctx.traceId, parentSpanId: ctx.spanId };
+  const activeSpan = trace.getSpan(context.active());
+  if (!activeSpan) {
+    return {};
+  }
+  const ctx = activeSpan.spanContext();
+  if (!isSpanContextValid(ctx)) {
+    return {};
+  }
+  return { traceId: ctx.traceId, parentSpanId: ctx.spanId };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/telemetry/traceContext.ts` around lines 12 - 22, Replace the manual
all-zero traceId check with the OpenTelemetry helper: after getting ctx =
activeSpan.spanContext() call isSpanContextValid(ctx) (imported from
'@opentelemetry/api') and return {} when it returns false; keep returning {
traceId: ctx.traceId, parentSpanId: ctx.spanId } when valid. Ensure you add the
isSpanContextValid import and remove the hard-coded traceId all-zero string
check so both traceId and spanId are validated.
src/lib/observability/utils/spanSerializer.ts (1)

37-47: Confirm intentional coupling of parentSpanId inheritance to traceId inheritance.

resolvedParentSpanId is only populated from getActiveTraceContext() inside the if (!resolvedTraceId) branch. So a caller that passes an explicit traceId but omits parentSpanId will end up with parentSpanId === undefined even when an active OTel span exists — breaking parent linkage for that Pipeline B span in Langfuse/OTel.

If callers always pair traceId+parentSpanId when they override, this is fine. Otherwise, consider lifting the parent inheritance out:

♻️ Optional refactor
-    let resolvedTraceId = traceId;
-    let resolvedParentSpanId = parentSpanId;
-    if (!resolvedTraceId) {
-      const otelCtx = getActiveTraceContext();
-      resolvedTraceId = otelCtx.traceId ?? randomBytes(16).toString("hex");
-      if (!resolvedParentSpanId && otelCtx.parentSpanId) {
-        resolvedParentSpanId = otelCtx.parentSpanId;
-      }
-    }
+    let resolvedTraceId = traceId;
+    let resolvedParentSpanId = parentSpanId;
+    if (!resolvedTraceId || !resolvedParentSpanId) {
+      const otelCtx = getActiveTraceContext();
+      if (!resolvedTraceId) {
+        resolvedTraceId = otelCtx.traceId ?? randomBytes(16).toString("hex");
+      }
+      if (!resolvedParentSpanId && otelCtx.parentSpanId) {
+        resolvedParentSpanId = otelCtx.parentSpanId;
+      }
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/observability/utils/spanSerializer.ts` around lines 37 - 47, The
current logic only inherits parentSpanId from getActiveTraceContext() when
traceId is not provided (see resolvedTraceId and resolvedParentSpanId handling),
causing callers that supply traceId but omit parentSpanId to lose parent
linkage; change the flow so you always call getActiveTraceContext() when
parentSpanId is missing (regardless of whether traceId was provided) and set
resolvedParentSpanId = otelCtx.parentSpanId if undefined, while preserving any
explicit parentSpanId; keep the existing resolvedTraceId fallback behavior
(randomBytes when otelCtx.traceId missing) but do not couple parentSpanId
inheritance to the traceId branch.
src/lib/server/routes/healthRoutes.ts (1)

230-292: Optional: memoize process.memoryUsage() in /health/detailed.

process.memoryUsage() is called five times within a single request handler. It's not free (it iterates all V8 heap spaces). Call it once into a local and reuse.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/server/routes/healthRoutes.ts` around lines 230 - 292, The handler
calls process.memoryUsage() multiple times; capture it once into a local (e.g.,
const mem = process.memoryUsage()) and replace all subsequent
process.memoryUsage() uses in this block (the heapUsed/heapTotal/rss/external
calculations and the span.setAttribute("health.memory.heap_mb") call) with
values derived from that cached mem object so the code uses the single sampled
memory snapshot instead of calling process.memoryUsage() repeatedly.
src/lib/server/routes/openApiRoutes.ts (1)

58-133: Optional: extract the duplicated JSON/YAML spec-building logic.

The JSON and YAML handlers both do routes = getRoutes?.() ?? [], the identical warn/debug log block, set the same openapi.route_count attribute, and construct new OpenAPIGenerator({ basePath, routes }). Consider a small helper (e.g., buildOpenApiSpec(span)) to avoid drift when one side is edited in isolation.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/server/routes/openApiRoutes.ts` around lines 58 - 133, The JSON and
YAML route handlers duplicate the same logic (calling getRoutes?.(), the
warn/debug logging, span.setAttribute("openapi.route_count", ...), and
instantiating new OpenAPIGenerator({ basePath, routes })), which risks drift;
extract that shared logic into a small helper (e.g., buildOpenApiSpec or
buildOpenApiContext) that accepts the span and returns { routes, generator } or
the final spec for each handler, then update the JSON handler to call
generator.generate() and the YAML handler to call generator.toYAML() while
keeping response wrapping (like _raw/contentType) in the YAML branch; reference
symbols: getRoutes, withSpan, OpenAPIGenerator,
span.setAttribute("openapi.route_count", ...), generator.generate,
generator.toYAML.
src/lib/providers/amazonBedrock.ts (2)

2256-2272: Throttling detection looks correct; consider also catching HTTP 429 as a fallback.

error.name === "ThrottlingException" is the canonical AWS SDK v3 surface for Bedrock rate limiting, so this will catch the common case. For defense in depth (e.g., gateway-level throttling or SDK variants that surface the throttle only via $metadata.httpStatusCode), you could additionally check (error as any)?.$metadata?.httpStatusCode === 429. Non-blocking.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/providers/amazonBedrock.ts` around lines 2256 - 2272, Add a fallback
check for HTTP 429 on top of the existing ThrottlingException checks: inside the
error handling where errName / errCode are inspected (the block that returns new
RateLimitError and new ProviderError), also inspect (error as
any)?.$metadata?.httpStatusCode === 429 and treat that as a rate-limit case; if
true, return the same RateLimitError (using the same message pattern and
"bedrock" provider) instead of falling through to ProviderError. Ensure you
reference the existing RateLimitError and ProviderError constructors and
preserve providerName in the ProviderError path.

1407-1508: Recommended refactor: the first-iteration inline chunk loop duplicates processStreamResponse.

The block that walks Bedrock stream chunks (contentBlockStart / contentBlockDelta text & toolUse / contentBlockStop with _inputBuffer JSON parsing / messageStop / metadata.usage) is now implemented twice — once inline inside the ReadableStream.start callback and again in processStreamResponse. The two copies must stay in lockstep (you already had to patch the messageStop → continue + metadata handling in both places for this PR), which is a maintenance hazard and an easy source of future drift.

Consider extracting a single consumeBedrockStream(response.stream, controller) helper that returns { stopReason, messageContent, usage } and call it from both call sites. No behavior change intended, just dedup.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/providers/amazonBedrock.ts` around lines 1407 - 1508, The inline loop
in the ReadableStream.start callback duplicates logic already in
processStreamResponse (handling contentBlockStart/contentBlockDelta text and
toolUse, contentBlockStop JSON _inputBuffer parsing, messageStop handling, and
metadata.usage accumulation), which risks drift; extract that chunk-processing
logic into a single helper (e.g., consumeBedrockStream) that accepts the Bedrock
async-iterable (response.stream) and the controller and returns the aggregated
result ({ stopReason, messageContent, usage }), then replace the inline loop in
ReadableStream.start and the existing call site to processStreamResponse to use
this new helper so both places share the same implementation.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f37b65b5-b3b4-48f9-bf61-8a3612b50281

📥 Commits

Reviewing files that changed from the base of the PR and between 49f56cd and d3ac082.

📒 Files selected for processing (69)
  • docs/telemetry-after-proof.txt
  • docs/telemetry-baseline-proof.txt
  • docs/telemetry-fix-completion-report.md
  • docs/telemetry-fix-diff.txt
  • docs/telemetry-fix-execution-plan.md
  • docs/telemetry-fix-plan.md
  • docs/telemetry-gaps-audit.md
  • src/cli/loop/session.ts
  • src/lib/auth/anthropicOAuth.ts
  • src/lib/auth/sessionManager.ts
  • src/lib/auth/tokenStore.ts
  • src/lib/client/httpClient.ts
  • src/lib/client/sseClient.ts
  • src/lib/client/streamingClient.ts
  • src/lib/client/wsClient.ts
  • src/lib/context/budgetChecker.ts
  • src/lib/context/contextCompactor.ts
  • src/lib/context/summarizationEngine.ts
  • src/lib/core/baseProvider.ts
  • src/lib/core/modules/GenerationHandler.ts
  • src/lib/core/modules/StreamHandler.ts
  • src/lib/core/modules/ToolsManager.ts
  • src/lib/evaluation/ragasEvaluator.ts
  • src/lib/mcp/batching/requestBatcher.ts
  • src/lib/mcp/httpRateLimiter.ts
  • src/lib/mcp/httpRetryHandler.ts
  • src/lib/mcp/mcpClientFactory.ts
  • src/lib/mcp/toolDiscoveryService.ts
  • src/lib/mcp/toolRegistry.ts
  • src/lib/memory/memoryRetrievalTools.ts
  • src/lib/neurolink.ts
  • src/lib/observability/utils/spanSerializer.ts
  • src/lib/processors/base/BaseFileProcessor.ts
  • src/lib/processors/registry/ProcessorRegistry.ts
  • src/lib/providers/amazonBedrock.ts
  • src/lib/providers/amazonSagemaker.ts
  • src/lib/providers/anthropic.ts
  • src/lib/providers/azureOpenai.ts
  • src/lib/providers/googleAiStudio.ts
  • src/lib/providers/googleVertex.ts
  • src/lib/providers/huggingFace.ts
  • src/lib/providers/litellm.ts
  • src/lib/providers/mistral.ts
  • src/lib/providers/ollama.ts
  • src/lib/providers/openAI.ts
  • src/lib/providers/openRouter.ts
  • src/lib/providers/openaiCompatible.ts
  • src/lib/rag/chunkers/BaseChunker.ts
  • src/lib/rag/ragIntegration.ts
  • src/lib/rag/reranker/reranker.ts
  • src/lib/rag/retrieval/vectorQueryTool.ts
  • src/lib/server/middleware/common.ts
  • src/lib/server/routes/agentRoutes.ts
  • src/lib/server/routes/claudeProxyRoutes.ts
  • src/lib/server/routes/healthRoutes.ts
  • src/lib/server/routes/mcpRoutes.ts
  • src/lib/server/routes/memoryRoutes.ts
  • src/lib/server/routes/openApiRoutes.ts
  • src/lib/server/routes/toolRoutes.ts
  • src/lib/services/server/ai/observability/instrumentation.ts
  • src/lib/telemetry/traceContext.ts
  • src/lib/telemetry/tracers.ts
  • src/lib/types/span.ts
  • src/lib/utils/toolEndEmitter.ts
  • src/lib/workflow/core/ensembleExecutor.ts
  • src/lib/workflow/core/judgeScorer.ts
  • src/lib/workflow/core/responseConditioner.ts
  • src/lib/workflow/core/workflowRunner.ts
  • test/telemetry-gaps-verification.ts

Comment thread docs/telemetry-fix-completion-report.md Outdated
Comment on lines +3 to +18
**Date**: 2026-04-16
**Status**: ✅ COMPLETE — 26/28 verification tests resolved
**Regressions**: 0

---

## Summary

| Metric | Before | After | Delta |
| ---------------------------------- | ------ | ----- | ------- |
| Verification tests confirming gaps | 28 | 2 | **-26** |
| Type errors | 0 | 0 | 0 |
| Bugfix regression tests | 48/48 | 48/48 | 0 |
| Total fixes applied | — | 22 | — |

## Gaps Resolved (25 FAIL + 1 SKIP = 26 of 28)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Update this report to match the current verification state.

This artifact still says 26/28 resolved and keeps A2 open, but the current PR objectives for this branch say telemetry verification is at 0/28 unresolved. Merging the stale report will confuse future debugging and release signoff.

Also applies to: 72-86

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/telemetry-fix-completion-report.md` around lines 3 - 18, Update the
stale status and summary table text in the report: replace the "**Status**: ✅
COMPLETE — 26/28 verification tests resolved" phrase and the "Gaps Resolved (25
FAIL + 1 SKIP = 26 of 28)" line with the current verification state (telemetry
at 0/28 unresolved, i.e., 28/28 resolved), adjust the summary table row
"Verification tests confirming gaps" and the "After"/"Delta" numbers to reflect
0 unresolved (or 28/28 resolved) and update "Total fixes applied" if needed;
also make the same textual fixes in the duplicated section later in the document
that contains the same status/table content.

Comment thread docs/telemetry-gaps-audit.md Outdated
Comment thread src/lib/client/sseClient.ts
Comment thread src/lib/client/wsClient.ts
Comment thread src/lib/client/wsClient.ts
Comment thread src/lib/rag/chunkers/BaseChunker.ts
Comment thread src/lib/server/routes/agentRoutes.ts Outdated
Comment thread src/lib/server/routes/mcpRoutes.ts
Comment thread src/lib/server/routes/toolRoutes.ts
Comment thread test/telemetry-gaps-verification.ts Outdated
@github-actions

Copy link
Copy Markdown
Contributor

🤖 AI Review & Build Compliance ✅

Status: AI analysis complete • Build rules validated • Ready for review

📊 View detailed analysis results

🛡️ Analysis Complete

  • ✅ Security scan (vulnerabilities, API keys)
  • ✅ TypeScript safety & code quality
  • ✅ Error handling & best practices
  • ✅ Build rule enforcement validated
  • ✅ Commit format & compliance checks

📋 Ready for Merge When

  • All CI checks passing
  • Manual review approved
  • Any AI-flagged issues resolved

🤖 AI analysis complete - check individual code comments for specific feedback

@murdore
murdore force-pushed the fix/telemetry-proxy-observability branch from cf3fd35 to 54fe3fa Compare April 17, 2026 11:58
@murdore

murdore commented Apr 17, 2026

Copy link
Copy Markdown
Contributor Author

Review Feedback Addressed (Cycle 3) + Proxy Cooling Fixes

Proxy Fixes (from user-reported bugs)

Issue Fix
No cooldown between requests — request B arriving 1ms after A exhausts retries hammers the same account Added coolingUntil timestamp to RuntimeAccountState. Accounts are marked cooling when 429 retries exhaust. Subsequent requests skip cooling accounts (fallback to all if ALL cooling). Cooling clears on successful response.
Primary account index drift — advancePrimaryIfCurrent permanently drifts, no recovery Added maybeResetPrimaryToHome() called at request start. When home primary (index 0) cooling expires, primaryAccountIndex resets to 0.

Cycle 3 CodeRabbit Comments (15 total)

# File Action Status
1 docs/telemetry-fix-completion-report.md Updated stale 26/28 → 28/28 status ✅
2 docs/telemetry-gaps-audit.md Fixed test filename reference ✅
3 src/lib/client/sseClient.ts Wrapped onError callback to mark span failed ✅
4 src/lib/client/wsClient.ts:146 Orphan span now marked ERROR (superseded) ✅
5 src/lib/client/wsClient.ts:235 disconnect() defers span end to onclose handler ✅
6 src/lib/mcp/batching/requestBatcher.ts Added void catch for late executor rejection ✅
7 src/lib/memory/memoryRetrievalTools.ts Updated comment — search is now literal (intentional) ✅
8 src/lib/providers/googleAiStudio.ts Preserve partial token counts on error ✅
9 src/lib/providers/ollama.ts Emit accumulated content instead of blank ✅
10 src/lib/providers/openAI.ts Style suggestion — deferred (not a bug) ⏭️
11 src/lib/rag/chunkers/BaseChunker.ts Deduplicated attrs: content_chars + content_bytes ✅
12 src/lib/server/routes/agentRoutes.ts Stream span lifetime — deferred (architectural) ⏭️
13 src/lib/server/routes/mcpRoutes.ts Fixed false positive: check truthy error value ✅
14 src/lib/server/routes/toolRoutes.ts Mark tools.get span ERROR on missing tool ✅
15 test/telemetry-gaps-verification.ts Added emitToolEndFromStepFinish detection ✅

13/15 fixed, 2 deferred (style nit + architectural pattern requiring larger refactor)

Validation

  • TypeScript: ✅ 0 errors
  • Prettier: ✅ All files formatted
  • ESLint: ✅ 0 errors
  • Pre-commit hooks: ✅ Passed
  • Full build + validation: ✅ Passed

@coderabbitai — All actionable comments addressed. Please re-review.

@murdore

murdore commented Apr 17, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Apr 17, 2026

Copy link
Copy Markdown
✅ Actions performed

Full review triggered.

@coderabbitai

coderabbitai Bot commented Apr 17, 2026

Copy link
Copy Markdown

@murdore — Thank you for the detailed Cycle 3 summary. The 13/15 fixes look solid, and the two deferred items are well-reasoned. A few highlights:

Proxy Cooling Fixes (beyond CodeRabbit scope — good proactive improvements):

  • The coolingUntil timestamp pattern correctly prevents thundering-herd behaviour on a freshly rate-limited account. The fallback-to-all-accounts when ALL are cooling ensures requests aren't silently dropped.
  • maybeResetPrimaryToHome() is the right recovery mechanism — without it, a drifted primaryAccountIndex would permanently deprioritize account 0 even after its cooldown expires.

Notable Cycle 3 Fixes:

  • #5 (wsClient.ts disconnect): Deferring the span end to onclose is the correct approach — it ensures the close code and reason are always captured on the span before it ends.
  • #13 (mcpRoutes.ts): Checking for a truthy error value (rather than just presence of the error key) correctly avoids false positives on error: null / error: "" shapes — good catch on the fix.
  • #8 (googleAiStudio.ts): Preserving partial token counts on the error path ensures Langfuse usage reporting doesn't silently zero out partially-consumed tokens.

Deferred items acknowledged:

  • #10 (openAI.ts style nit) — reasonable deferral; no correctness impact.
  • #12 (agentRoutes.ts stream span lifetime) — agreed this needs a larger architectural refactor; appropriate to defer to a follow-up PR.

Triggering the full re-review now.


✏️ Learnings added
Learnt from: murdore
URL: https://github.com/juspay/neurolink/pull/962

Timestamp: 2026-04-17T11:59:04.411Z
Learning: In src/lib/server/routes/claudeProxyRoutes.ts (neurolink), the RuntimeAccountState tracks a `coolingUntil` timestamp. When 429 retries are exhausted for an account, it is marked as cooling. Subsequent requests skip cooling accounts (with fallback to all accounts if ALL are cooling). Cooling clears on successful response. This prevents thundering-herd hammering of a rate-limited account.

Learnt from: murdore
URL: https://github.com/juspay/neurolink/pull/962

Timestamp: 2026-04-17T11:59:04.411Z
Learning: In src/lib/server/routes/claudeProxyRoutes.ts (neurolink), `maybeResetPrimaryToHome()` is called at request start to recover from primary account index drift. When the home primary (index 0) cooling expires, primaryAccountIndex resets to 0, preventing permanent drift caused by `advancePrimaryIfCurrent`.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

✅ Actions performed

Full review triggered.

@github-actions

Copy link
Copy Markdown
Contributor

🤖 AI Review & Build Compliance ✅

Status: AI analysis complete • Build rules validated • Ready for review

📊 View detailed analysis results

🛡️ Analysis Complete

  • ✅ Security scan (vulnerabilities, API keys)
  • ✅ TypeScript safety & code quality
  • ✅ Error handling & best practices
  • ✅ Build rule enforcement validated
  • ✅ Commit format & compliance checks

📋 Ready for Merge When

  • All CI checks passing
  • Manual review approved
  • Any AI-flagged issues resolved

🤖 AI analysis complete - check individual code comments for specific feedback

@github-actions

Copy link
Copy Markdown
Contributor

🤖 AI Review & Build Compliance ✅

Status: AI analysis complete • Build rules validated • Ready for review

📊 View detailed analysis results

🛡️ Analysis Complete

  • ✅ Security scan (vulnerabilities, API keys)
  • ✅ TypeScript safety & code quality
  • ✅ Error handling & best practices
  • ✅ Build rule enforcement validated
  • ✅ Commit format & compliance checks

📋 Ready for Merge When

  • All CI checks passing
  • Manual review approved
  • Any AI-flagged issues resolved

🤖 AI analysis complete - check individual code comments for specific feedback

@murdore

murdore commented Apr 18, 2026

Copy link
Copy Markdown
Contributor Author

Additional scope: type-consolidation refactor (22 commits, fbd94836..a3c0983a)

On top of the telemetry/proxy work, this branch now also completes the full type-consolidation refactor:

  • 216 local type aliases across ~120 files → 0 local (every declaration now lives in src/lib/types/)
  • New ESLint rule neurolink/no-local-type-alias in eslint-rules/ locks the invariant at commit time (catches what the old inventory script missed — aliases declared inside function bodies)
  • Superset-merge rule applied across the codebase: if A is a superset of B, B's call sites migrate to A and B is deleted. Result: 35 type duplicates removed.
  • Materialisation discipline: types that previously reached back into runtime modules via typeof X / import("runtime").Y / ReturnType<typeof fn> are now spelled out structurally in the barrel (e.g. ProxyNeurolinkRuntime.neurolink materialised as { getToolRegistry(): MCPToolRegistry } instead of importing the NeuroLink class; zod-derived types like CliNeuroLinkConfig materialised to full structural form with z.ZodType<T> drift protection on the runtime schema).
  • Rule 9 collisions resolved via domain prefixes (AuthRateLimitConfig, AiSdkStreamChunk, ObservabilityModelPricing, SageMakerOpenAIToolCall, CliServeRouteGroup, etc.).
  • Scratch tooling removed (scripts/type-consolidation/, .proof-of-work/, .type-consolidation/).
  • CLAUDE.md rule table updated to include the new rule.

API surface delta vs release

  • Types barrel export * set: identical
  • NeuroLink class public methods: identical
  • Public type names: +~100 added (types promoted from feature files into the barrel — additive, non-breaking), 1 removed (AsyncRetryOptions superset-merged into RetryOptions).

Validation

  • pnpm exec tsc --noEmit — 0 errors
  • pnpm run lint — 0 errors (14 pre-existing warnings)
  • pnpm run build — SDK + CLI + browser + publint all pass
  • Inventory script: 0 local aliases
  • Negative probe on the new ESLint rule (planted type X = ...) flagged correctly; positive probes on src/lib/types/ and test files exempted correctly

Not run in this session: pnpm test (continuous-test-suite requires live AI provider credentials).

@github-actions

Copy link
Copy Markdown
Contributor

🤖 AI Review & Build Compliance ✅

Status: AI analysis complete • Build rules validated • Ready for review

📊 View detailed analysis results

🛡️ Analysis Complete

  • ✅ Security scan (vulnerabilities, API keys)
  • ✅ TypeScript safety & code quality
  • ✅ Error handling & best practices
  • ✅ Build rule enforcement validated
  • ✅ Commit format & compliance checks

📋 Ready for Merge When

  • All CI checks passing
  • Manual review approved
  • Any AI-flagged issues resolved

🤖 AI analysis complete - check individual code comments for specific feedback

@github-actions

Copy link
Copy Markdown
Contributor

🤖 AI Review & Build Compliance ✅

Status: AI analysis complete • Build rules validated • Ready for review

📊 View detailed analysis results

🛡️ Analysis Complete

  • ✅ Security scan (vulnerabilities, API keys)
  • ✅ TypeScript safety & code quality
  • ✅ Error handling & best practices
  • ✅ Build rule enforcement validated
  • ✅ Commit format & compliance checks

📋 Ready for Merge When

  • All CI checks passing
  • Manual review approved
  • Any AI-flagged issues resolved

🤖 AI analysis complete - check individual code comments for specific feedback

@github-actions

Copy link
Copy Markdown
Contributor

🤖 AI Review & Build Compliance ✅

Status: AI analysis complete • Build rules validated • Ready for review

📊 View detailed analysis results

🛡️ Analysis Complete

  • ✅ Security scan (vulnerabilities, API keys)
  • ✅ TypeScript safety & code quality
  • ✅ Error handling & best practices
  • ✅ Build rule enforcement validated
  • ✅ Commit format & compliance checks

📋 Ready for Merge When

  • All CI checks passing
  • Manual review approved
  • Any AI-flagged issues resolved

🤖 AI analysis complete - check individual code comments for specific feedback

…rden proxy retry

- Close 28/28 OTel/Langfuse telemetry gaps across all providers
- Relocate 262 local type declarations into canonical src/lib/types/
- Add no-local-type-alias ESLint rule to prevent future violations
- Add per-account coolingUntil + maybeResetPrimaryToHome for proxy 429 handling
- Increase MAX_RATE_LIMIT_SAME_ACCOUNT_RETRIES to 10
- Fix ReDoS in memoryRetrievalTools, Array.isArray validation in proxy
- Add failure generation:end emits for Bedrock, Google AI Studio, Ollama
- Fix Pipeline B spanId uniqueness, step_count source, stream span lifecycle
@github-actions

Copy link
Copy Markdown
Contributor

🤖 AI Review & Build Compliance ✅

Status: AI analysis complete • Build rules validated • Ready for review

📊 View detailed analysis results

🛡️ Analysis Complete

  • ✅ Security scan (vulnerabilities, API keys)
  • ✅ TypeScript safety & code quality
  • ✅ Error handling & best practices
  • ✅ Build rule enforcement validated
  • ✅ Commit format & compliance checks

📋 Ready for Merge When

  • All CI checks passing
  • Manual review approved
  • Any AI-flagged issues resolved

🤖 AI analysis complete - check individual code comments for specific feedback

@murdore
murdore merged commit 85d9b91 into release Apr 18, 2026
16 checks passed
@murdore
murdore deleted the fix/telemetry-proxy-observability branch April 18, 2026 11:00
@github-actions

Copy link
Copy Markdown
Contributor

🎉 This PR is included in version 9.54.7 🎉

The release is available on:

Your semantic-release bot 📦🚀

This branch was successfully deployed

1 active deployment
Preview — d7713e97 Deployed Apr 18, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants