Announcing CIMD support for MCP Client registration
Learn more

See Every Scalekit Tool Call Next to Your LLM Replies in One Arize Trace

Saif Ali Shaik
Founding Developer Advocate

TL;DR

  • One in-process OpenTelemetry tree: AGENT turn, LLM spans from Arize's OpenAI instrumentor, TOOL spans from a thin wrapper around client.tools.executeTool. Same trace ID, free parent/child nesting, no traceparent plumbing to Scalekit's servers.
  • Discover tools for an identifier with listAvailableTools (or listScopedTools when you have a connector filter)
  • Execute with tracedTools(scalekit).executeTool(...) so each call emits an OpenInference TOOL span
  • Mark resolved connector failures as ERROR (most AgentKit failures do not throw)
  • Export to Arize over OTLP HTTP; keep a local span tree for demos

You wire Scalekit so your agent can call Slack, GitHub, or Calendar as a real connected user. The model decides on a tool. Scalekit runs executeTool. The user sees a confident "Done!" in the chat.

In Arize you open the trace and see only LLM spans. The model's request to call the tool is there: name and arguments. The actual tool run is not. Neither is the fact that Scalekit returned missing_scope while the model still said success.

That gap is not an Arize bug and not a Scalekit bug. Provider auto-instrumentors (OpenAI, Anthropic) patch the LLM client. They never see your application called Scalekit. Without an application-level TOOL span, the trace is a flat list of chat completions with a hole where reality happened.

This post shows how to close that hole with Scalekit's public API, OpenInference semantic conventions, and Arize AX as the backend, without monkey-patching the Node SDK.

By the end of this tutorial you will have:

  • A clear mental model for what LLM auto-instrumentation does and does not capture.
  • A copy-paste-ready tracedTools wrapper typed against @scalekit-sdk/node's public signature.
  • An agent loop that nests TOOL under AGENT and LLM under the same parent.
  • Next.js / OpenTelemetry setup notes that prevent "TOOL spans but no LLM spans."
  • A cloneable prototype: ecosystem/arize-agentkit-tracing-demo.

Honest note: The reference demo typechecks, builds, and passes offline span-contract tests against real ExecuteToolResponse envelope shapes. It has also been run end to end against a live Scalekit environment and a real Arize AX project: one trace contains AGENT run_agent (OK) with LLM and TOOL children, and the TOOL spans for failing GitHub connector calls exported as ERROR, the failure path this post is about. The success path (a connector call that returns cleanly and should export OK) has so far only been verified against the offline stub, so treat isErrorResult as validated for failures and unproven for successes until you see a green TOOL span in your own project.

Architecture Overview

Scalekit owns connected accounts, scopes, and tool execution. Arize AX owns storage, search, and evaluation of OpenInference traces. Your app owns the agent loop and the decision to emit TOOL spans in process; next to the LLM spans, under one AGENT root.

Walk through one turn:

  1. You start an AGENT span for the turn.
  2. The model runs under the OpenAI instrumentor → LLM span(s).
  3. On tool_calls, you call Scalekit through tracedTools → TOOL span as a child of the active context.
  4. Results go back to the model; another LLM span may claim success or report the error.
  5. The batch exporter ships the whole tree to Arize.

Key point: Instrumenting tool execution in your process is what keeps TOOL nested under AGENT without stitching. A hypothetical Scalekit-side span exporter would need W3C traceparent (or equivalent) or you would get orphan tool traces.

Who Is This For

This post is for teams shipping tool-using agents on Scalekit AgentKit who already care about LLM observability: Arize, Phoenix, or any OpenInference-compatible backend, and have noticed that "we instrumented OpenAI" still leaves tool outcomes invisible. You may be debugging flaky connectors, auditing multi-tenant identifier usage, or building evals that need ground-truth tool success/failure next to model text. You are not looking for another OAuth tutorial; you already have connected accounts. You need the runtime story in the trace.

The Problem

Stock instrumentation gives you something like:

LLM OpenAI Chat Completions → tool_calls: slack.chat.postMessage LLM OpenAI Chat Completions → "Message sent!"

What you need for the failure case:

AGENT run_agent 4.2s ├─ LLM OpenAI Chat Completions 0.8s wants tool: slack.chat.postMessage ├─ TOOL slack.chat.postMessage 0.3s ERROR missing_scope └─ LLM OpenAI Chat Completions 1.1s "Sent!" ← now visible next to ERROR

(OpenAI Chat Completions is the span name the OpenInference instrumentor emits — that is the string you search for in Arize, not the chat.completions.create method name.)

Here is the same shape from a real run of the reference demo, read back out of Arize:

AGENT run_agent 11.7s OK ├─ TOOL github_search_code 0.35s ERROR ├─ LLM OpenAI Chat Completions — OK └─ TOOL github_search_issues 0.31s ERROR

The AGENT span closes OK while two TOOL children are ERROR. That contrast — a turn that "succeeded" containing tools that did not — is the entire reason to put both in one trace.

Only the combined tree makes the lie obvious. Scalekit alone knows the tool failed. Arize alone knows what the model said. One trace with both is the product surface.

The Solution: A Public-API Wrapper, Not a Monkey-Patch

The tempting approach is Object.getPrototypeOf(client.tools) and patching executeTool. Do not ship that.

ToolsClient is not exported from @scalekit-sdk/node, only ScalekitClient is. A prototype patch reaches into internals the SDK never promised. When those internals move, the patch stops applying and spans silently disappear. No throw, no warning. Observability that fails closed is better than observability that fails invisible.

Instead, wrap the documented call site:

type ExecuteToolParams = Parameters[0]; type ExecuteToolResult = Awaited< ReturnType >;

If executeTool changes shape, TypeScript fails at build time. That is the integration contract you want for a recipe that may later become a package.

Prerequisites

  • Node.js 20+
  • A Scalekit environment with AgentKit and at least one Active connected account (identifier from Dashboard → AgentKit → Connected Accounts)
  • OpenAI API key or any OpenAI-compatible proxy (LiteLLM, gateway) via OPENAI_BASE_URL
  • An Arize AX space: Space ID, API key, project name, and the OTLP endpoint for your region (US / EU / Canada — do not assume US)

Optional for local-only demos: omit Arize credentials; the reference app still builds an in-memory span tree in the UI.

Step 1: Install and Configure

npm install @scalekit-sdk/node openai \ @opentelemetry/api \ @opentelemetry/sdk-trace-node \ @opentelemetry/exporter-trace-otlp-proto \ @opentelemetry/resources \ @opentelemetry/semantic-conventions \ @opentelemetry/instrumentation \ @arizeai/openinference-instrumentation-openai \ @arizeai/openinference-semantic-conventions

Environment (names match the demo):

Variable
Purpose
SCALEKIT_ENV_URL, SCALEKIT_CLIENT_ID, SCALEKIT_CLIENT_SECRET
Dashboard → Developers → API credentials
TEST_IDENTIFIER (or your session-resolved identifier)
Active connected account
OPENAI_API_KEY
Or proxy key
OPENAI_BASE_URL / OPENAI_MODEL
Optional proxy + model id
ARIZE_SPACE_ID, ARIZE_API_KEY, ARIZE_PROJECT_NAME
Required for export (ARIZE_PROJECT_NAME is not optional — Arize can 500 without it)
ARIZE_COLLECTOR_ENDPOINT
e.g. https://otlp.arize.com/v1/traces (US); use EU/Canada hosts if your space is there

Checkpoint: You can construct ScalekitClient and list a non-empty set of tools for the identifier. An empty list here is a connected-account problem, not a tracing problem — fix it before wiring spans.

Step 2: Discover Tools for the Identifier

Keep discovery connector-agnostic. Whatever the identifier is authorized to call — GitHub, Gmail, Slack, Calendar — becomes the model's tool list. This is especially important for multi-tenant tool calling where each identifier may have a different set of authorized connectors.

import { ScalekitClient } from '@scalekit-sdk/node'; const scalekit = new ScalekitClient( process.env.SCALEKIT_ENV_URL!, process.env.SCALEKIT_CLIENT_ID!, process.env.SCALEKIT_CLIENT_SECRET! ); const identifier = process.env.TEST_IDENTIFIER!; // Default path: everything this identifier can call, no connector filter needed. const { tools } = await scalekit.tools.listAvailableTools(identifier, { pageSize: 100, }); // Normalize protobuf Struct definitions into OpenAI function tools. // Log one raw definition in dev so you know what *your* connectors return.

Watch the signatures — these three are not interchangeable:

// identifier is a positional arg, and `filter` is REQUIRED (the API rejects // an empty filter with "no ... specified in filter"). scalekit.tools.listScopedTools(identifier, { filter: { providers: ['github'], toolNames: [], connectionNames: [] }, pageSize: 100, }); // No filter, identifier positional. scalekit.tools.listAvailableTools(identifier, { pageSize: 100 }); // Identifier goes *inside* filter here. scalekit.tools.listTools({ filter: { identifier }, pageSize: 100 });

Reach for listScopedTools only when you actually have a provider or connection filter to pass. The reference demo defaults to listAvailableTools and falls back to listTools({ filter: { identifier } }) when the available list comes back empty — common when your identifier is a connection id rather than an email.

Pass a specFor(toolName) lookup into the tracer so TOOL spans get OpenInference's tool.description and tool.parameters, not only the name.

Step 3: Wrap executeTool with OpenInference TOOL Spans

This is the integration surface worth extracting later. For context on why tool calling authentication requires careful runtime instrumentation — not just static config — see our dedicated guide. Conceptually:

import { trace, SpanStatusCode } from '@opentelemetry/api'; import { INPUT_VALUE, OUTPUT_VALUE, SemanticConventions, OpenInferenceSpanKind, } from '@arizeai/openinference-semantic-conventions'; import type { ScalekitClient } from '@scalekit-sdk/node'; type ExecuteToolParams = Parameters[0]; type ExecuteToolResult = Awaited< ReturnType >; export function tracedTools( client: ScalekitClient, options: { specFor?: (name: string) => { description?: string; parameters?: unknown }; isErrorResult?: (result: ExecuteToolResult) => boolean; } = {} ) { const tracer = trace.getTracer('scalekit-agentkit'); const isErrorResult = options.isErrorResult ?? defaultIsErrorResult; return { async executeTool(params: ExecuteToolParams, toolCallId?: string) { const spec = options.specFor?.(params.toolName); return tracer.startActiveSpan(params.toolName, async (span) => { span.setAttributes({ [SemanticConventions.OPENINFERENCE_SPAN_KIND]: OpenInferenceSpanKind.TOOL, [SemanticConventions.TOOL_NAME]: params.toolName, [SemanticConventions.TOOL_DESCRIPTION]: spec?.description ?? `Scalekit tool: ${params.toolName}`, [SemanticConventions.TOOL_PARAMETERS]: JSON.stringify( spec?.parameters ?? {} ), [INPUT_VALUE]: JSON.stringify(params.params ?? {}), ...(params.identifier ? { 'scalekit.identifier': params.identifier } : {}), }); if (toolCallId) { span.setAttribute(SemanticConventions.TOOL_ID, toolCallId); } try { const result = await client.tools.executeTool(params); span.setAttribute(OUTPUT_VALUE, JSON.stringify(result)); if (isErrorResult(result)) { span.setStatus({ code: SpanStatusCode.ERROR, message: 'Tool returned an error result', }); } else { // startActiveSpan does not set OK for you span.setStatus({ code: SpanStatusCode.OK }); } return result; } catch (error) { span.recordException(error as Error); span.setStatus({ code: SpanStatusCode.ERROR, message: (error as Error).message, }); throw error; } finally { span.end(); } }); }, }; }

Call site in the agent loop:

const tools = tracedTools(scalekit, { specFor: lookupFromDiscovery }); const result = await tools.executeTool( { toolName: toolCall.function.name, identifier, params: JSON.parse(toolCall.function.arguments || '{}'), }, toolCall.id // links TOOL span to the model's tool_call.id );

Checkpoint: Offline tests (or a deliberate failure) show TOOL spans with OK vs ERROR matching success vs failure-in-data.

Why Error Classification Is the Hard Part

The Node SDK types success as roughly:

Promise<{ data?: JsonObject; executionId: string }>

Connector failures (missing scope, provider 4xx, { ok: false, error: "..." }) almost always arrive as a resolved promise with the failure inside data — not as a throw, and not as a top-level error on the envelope.

If you only mark ERROR on throw, those spans export as OK and the headline demo is dead. The default classifier should unwrap data and look for structural cues (ok === false, error present, statusCode >= 400, status strings like failed). That is still a guess about connector payloads. Run a tool you know will fail, inspect result.data, and tighten isErrorResult for your connectors.

Step 4: Own the AGENT Span; Let the Instrumentor Own LLM

import { trace, SpanStatusCode } from '@opentelemetry/api'; import { INPUT_VALUE, OUTPUT_VALUE, SemanticConventions, OpenInferenceSpanKind, } from '@arizeai/openinference-semantic-conventions'; const tracer = trace.getTracer('my-agent'); return tracer.startActiveSpan('run_agent', async (span) => { span.setAttribute( SemanticConventions.OPENINFERENCE_SPAN_KIND, OpenInferenceSpanKind.AGENT ); span.setAttribute(INPUT_VALUE, userMessage); span.setAttribute('scalekit.identifier', identifier); // chat.completions.create → LLM spans (automatic if OpenAI is instrumented) // tools.executeTool → TOOL spans (your wrapper) // feed tool results back; loop until final text span.setAttribute(OUTPUT_VALUE, finalReply); span.setStatus({ code: SpanStatusCode.OK }); span.end(); });

Construct the OpenAI client lazily after the tracer and instrumentor register. A client created at module import time can miss the patch and emit zero LLM spans while TOOL spans still work — a hole that looks like a half-broken integration.

Step 5: Register Arize / OpenTelemetry (Next.js Notes)

Order is fixed:

  1. Register NodeTracerProvider (resource must include Arize project name).
  2. Register instrumentors; for Next.js, call manuallyInstrument on the imported openai module.
  3. Then construct OpenAI clients.

Sketch:

import { NodeTracerProvider, BatchSpanProcessor } from '@opentelemetry/sdk-trace-node'; import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto'; import { resourceFromAttributes } from '@opentelemetry/resources'; import { ATTR_SERVICE_NAME } from '@opentelemetry/semantic-conventions'; import { SEMRESATTRS_PROJECT_NAME } from '@arizeai/openinference-semantic-conventions'; import { OpenAIInstrumentation } from '@arizeai/openinference-instrumentation-openai'; import { registerInstrumentations } from '@opentelemetry/instrumentation'; // The HTTP exporter needs the signal-specific path. Arize's docs and the gRPC // transport both use the bare `/v1` base, so accept either rather than failing // as a silent no-op export. function normalizeToTracesPath(endpoint: string): string { const trimmed = endpoint.replace(/\/+$/, ''); return trimmed.endsWith('/v1') ? `${trimmed}/traces` : trimmed; } const provider = new NodeTracerProvider({ resource: resourceFromAttributes({ [SEMRESATTRS_PROJECT_NAME]: process.env.ARIZE_PROJECT_NAME!, [ATTR_SERVICE_NAME]: 'my-scalekit-agent', }), spanProcessors: [ new BatchSpanProcessor( new OTLPTraceExporter({ url: normalizeToTracesPath(process.env.ARIZE_COLLECTOR_ENDPOINT!), headers: { space_id: process.env.ARIZE_SPACE_ID!, api_key: process.env.ARIZE_API_KEY!, }, }) ), ], }); provider.register(); const openAIInstrumentation = new OpenAIInstrumentation(); registerInstrumentations({ instrumentations: [openAIInstrumentation] }); const openaiModule = await import('openai'); openAIInstrumentation.manuallyInstrument(openaiModule);

Next.js-specific gotchas (these cost people full afternoons):

Issue
Fix
Bundler rewrites module identity; auto-hook never sees openai
manuallyInstrument on the real module object
Two copies of openai (bundled vs external)
Put openai in serverExternalPackages so agent and instrumentor share one module
OTel resources v2
Use resourceFromAttributes(), not new Resource()
Wrong Arize region
Empty project, no error in the happy path of your app — check endpoint table
Missing ARIZE_PROJECT_NAME
Export can fail with HTTP 500
OTLP path
HTTP exporter wants /v1/traces; bare /v1 (gRPC-style base) should be normalized

In the App Router, put setup in instrumentation.ts so it runs before route modules load:

export async function register() { if (process.env.NEXT_RUNTIME !== 'nodejs') return; const { initArize } = await import('./lib/arize'); await initArize(); }

After a demo turn, forceFlush() the provider so the batch is more likely to appear in Arize before you refresh the UI.

Step 6: Verify the Span Contract (Before You Trust the UI)

The demo ships npm run verify: a stub Scalekit client returning real envelope shapes.

Case
Expected span status
Tool succeeds (data.ok: true or clean payload)
OK
Failure inside data (resolved, not thrown)
ERROR
executeTool throws
ERROR + exception recorded
Nested statusCode / error under data
ERROR

The middle rows are the ones people get wrong first.

For a live check: revoke a scope or use a bad resource id, run one agent turn, open Arize by trace_id, and confirm TOOL is ERROR while the final LLM content is still optimistic. That is the screenshot worth putting in an incident channel.

Privacy and Multi-Tenant Attributes

Demo spans often include full tool arguments, full results, and chat content. scalekit.identifier is frequently an email. That is useful locally and not a production default.

Before you reuse the recipe:

  • Hash or redact identifiers in span attributes.
  • Drop or scrub PII from input.value / output.value.
  • Do not return raw span attributes to untrusted browsers (the demo does this on purpose for teaching).
  • Use Arize project-side scrubbing where available.
  • Prefer attributes like scalekit.organization_id / scalekit.user_id from authenticated connected-account context over free-form strings the client asserted.

Those Scalekit fields are what make tenant filters in Arize trustworthy. For a deeper look at access control in multi-tenant AI agents, see our dedicated guide.

Complete Flow (Minimal)

// 1. Init OTel + OpenAI instrumentor (once, before clients) // 2. Discover tools for identifier // 3. const tools = tracedTools(scalekit, { specFor }) // 4. startActiveSpan('run_agent', AGENT attributes) // 5. loop: completions.create → for each tool_call → tools.executeTool → tool message // 6. end AGENT; forceFlush()

Clone and run the full Next.js app from the prototype README: install, copy .env.example.env.local, npm run verify, npm run dev, open http://localhost:3000, send a prompt that forces a connected tool, then open Arize with the printed trace id.

Common Scenarios

  • Internal agent for a team: Each teammate has their own Scalekit identifier; traces filter by scalekit.identifier or org id when you debug "why did Slack fail for Alice but not Bob?"
  • Customer-facing product agent: Resolve identifier from your session after auth; never accept it from the browser as gospel. Tool spans show which tenant's connection failed.
  • LiteLLM / gateway: Point OPENAI_BASE_URL at the proxy. OpenInference still patches the OpenAI SDK; destination host does not remove LLM spans.
  • Evals: Score "tool ERROR + model claimed success" as a first-class failure mode once both sit in one trace.
  • Future package: Keep traced-tools.ts on public types only so promotion from recipe → npm package does not require a rewrite.

Troubleshooting

I only see LLM spans in Arize

You never wrap executeTool, or the wrapper runs outside an active AGENT span and you are looking at a different filter. Confirm TOOL span kind and tool.name attributes. Confirm the agent actually called a tool this turn.

I only see TOOL spans, no LLM spans

Classic Next/bundler issue: instrumentor patched a different openai instance than the agent imports. Use manuallyInstrument + serverExternalPackages: ['openai']. Also confirm the client is created after register().

TOOL spans are always OK even when the connector failed

Your isErrorResult is not unwrapping data. Log JSON.stringify(result) for a known failure and fix the classifier.

No traces in Arize at all

Wrong region endpoint, missing project name, missing space/api headers, or batch not flushed before process exit. Local collector/UI may still show spans while export is broken — treat those as separate pipelines.

Spans not recording (isRecording() === false)

Duplicate @opentelemetry/api versions or TracerProvider registered in a different realm than the agent module. Align versions and init order.

Tool discovery returns nothing

Identifier has no Active connected account, or you filtered connectors incorrectly. Fix connection state in the Scalekit dashboard before debugging tracing.

If you called listScopedTools, note that its filter is required and the API rejects an empty one (no ... specified in filter) — that reads as "no tools" but is really a bad request. With no filter to pass, use listAvailableTools(identifier), and fall back to listTools({ filter: { identifier } }) when the available list is empty.

Every TOOL span is ERROR, including calls that should have worked

The mirror image of the classifier bug: if isErrorResult is too eager it flags healthy payloads too, and a wall of red hides the real failures. Confirm against a call you know succeeded — if its span is still ERROR, log JSON.stringify(result) and tighten the structural checks.

Tradeoffs and Limitations

Approach
Pros
Cons
When to use
LLM auto-instrumentation only
Zero app code
No tool outcomes; misses the failure class that matters
Never enough for tool-using agents
Monkey-patch ToolsClient internals
Feels "automatic"
Silent break on SDK change; non-public API
Avoid
Public tracedTools wrapper
Typed to SDK; in-process nesting; extractable
You must call the wrapper; error shapes need care
Default for AgentKit + Arize
Scalekit emits spans server-side (hypothetical)
Centralized
Needs context propagation; orphans without it
Only with a real propagation design

Known gap: Error detection remains structural until Scalekit exposes a first-class error channel on the execute envelope. Treat isErrorResult as configuration per connector family, not as a permanent universal truth.

What's Next

  • Run the prototype against a failed tool and save the Arize tree as your team's definition of done for agent observability.
  • Redact production attributes; keep scalekit.* tenant keys you actually filter on.
  • Reuse the same wrapper under LangGraph, Mastra, or a raw loop — the integration is the execute boundary, not the framework. For instance, see how tool-calling auth patterns apply across different agent frameworks.
  • Review credential ownership patterns to understand who should hold tokens at each layer of your agent architecture.
  • Read Scalekit docs for connectors and executeTool.
  • Read Arize OpenInference docs for span kinds and project setup.
No items found.
Agent
Auth Quickstart
On this page
Share this article
Agent
Auth Quickstart

Acquire enterprise customers with
zero upfront cost.

Every feature unlocked. No hidden fees.