keelwave
SDKsTypeScript

Span & decorators

Wrap functions with observe and agent, or time a block of work with Span.

Three wrappers cover most instrumentation without touching a Run directly: client.agent() opens a run around a function, client.observe() records a function call as a step, and client.span() times an arbitrary block.

import { Keelwave } from 'keelwave'

const client = new Keelwave({ apiKey: process.env.KEELWAVE_API_KEY ?? '' })

const webSearch = client.observe({ name: 'web_search', stepType: 'tool_call' })(
  async (q: string): Promise<{ results: Array<string> }> => {
    return { results: [`result for: ${q}`] }
  },
)

const runAgent = client.agent({ name: 'demo-agent' })(async (task: string) => {
  const { results } = await webSearch(task)
  return `Found: ${results[0]}`
})

const answer = await runAgent('TypeScript observability')
console.log(answer)

That is a complete traced agent: one run, one fingerprinted tool call, and the return value stored as the run output.

client.agent()

Wraps an async function so each call opens a run, runs the function, stores its return value as the run output, and closes the run.

// with options
const runAgent = client.agent({ name: 'demo-agent' })(async (task: string) => {
  return `done: ${task}`
})

// bare form — the agent name comes from the function name
const research = client.agent(async function research(task: string) {
  return `done: ${task}`
})

AgentOptions

FieldTypeDefaultNotes
namestringfunction name, else 'agent'Agent name for the run.

Behaviour worth knowing:

  • The first argument is stringified and stored as the run input (truncated to 10,000 characters).
  • A non-null/undefined return value is stored as the run output (truncated to 100,000 characters).
  • Inside the wrapped function, getCurrentRun() returns the live run.
import { getCurrentRun } from 'keelwave'

const runLoop = client.agent({ name: 'looping-agent' })(async (q: string) => {
  const run = getCurrentRun()!
  for (let i = 0; i < 5; i++) await webSearch(q)
  return run.id
})

agent() only wraps async functions — its type parameter requires a function returning a Promise.

client.observe()

Wraps any function — sync or async — so each call is recorded. The wrapper preserves the return value and rethrows errors unchanged.

// options form
const search = client.observe({ name: 'search', stepType: 'tool_call' })(
  async (q: string) => ({ results: [q] }),
)

// bare form — the step name comes from the function name
const double = client.observe((x: number) => x * 2)
double(21) // → 42

ObserveOptions

FieldTypeDefaultNotes
namestringfunction name, else 'fn'Label recorded for the call.
stepTypestring'tool_call'Step type. 'tool_call' enables fingerprinting.

What gets recorded

observe behaves differently depending on context and stepType:

ContextResult
Inside a run, stepType: 'tool_call'run.toolCall(name, input, output, { ok, latencyMs }) — fingerprinted for loop detection.
Inside a run, any other stepTyperun.step(stepType, String(result), { metadata: { fn, latency_ms } }).
Outside a runclient.ingestAi({ model: name, provider: 'observe', status, latencyMs, metadata }).

Arguments are turned into the recorded input: a single plain-object argument is used as-is, otherwise they are wrapped as { args }. The output is the return value if it is a plain object, { value: String(result) } otherwise, or { error } when the function threw. Strings are truncated to 500 characters.

Success is derived from whether the function threw — there is no ok option on ObserveOptions.

const flaky = client.observe({ name: 'flaky' })(async () => {
  throw new Error('boom')
})

await flaky().catch(() => {})
// step recorded with ok: false and output { error: "Error: boom" }

For synchronous functions the emit is fire-and-forget: the wrapper returns immediately and the step is sent in the background. Async functions await the emit before resolving. Either way, a failed emit follows the client's raiseOnError policy and never breaks the caller.

Span

client.span() records a step for a block of work you time yourself. Use it when the unit of work isn't a single function call.

await client.run('span-test', async () => {
  const s = client.span('think', 'reasoning-step').start()
  s.set('thinking about the problem')
  await s.end()
})

Signature: span(stepType?: string, name?: string): Span. stepType defaults to 'span'; name is optional.

Span methods

MethodNotes
start()Marks the start time. Returns this, so it chains.
set(content?, opts?)Sets step content, opts.tokens, and opts.metadata. Returns this.
end()Emits the step. Returns Promise<void>.
[Symbol.asyncDispose]()Same as end(), for await using.

set takes { tokens?: number; metadata?: Record<string, unknown> }. Only the fields you pass are applied, so repeated calls accumulate.

const s = client.span('retrieve', 'vector-lookup').start()
s.set('queried the index', { tokens: 42, metadata: { topK: 5 } })
await s.end()

On end() the span emits run.step(stepType, content, { tokens, metadata }). The metadata is your metadata plus latency_ms (when start() was called) and span_name (when a name was given).

A span only emits when there is an active run. Called outside a run, end() silently does nothing.

await using

Span implements Symbol.asyncDispose, so on a runtime and TypeScript version with explicit resource management you can let the span close itself:

await client.run('disposable', async () => {
  await using s = client.span('think', 'reasoning-step').start()
  s.set('thinking about the problem')
  // the step is emitted when the block exits
})

If your build target does not support await using, call end() explicitly — it is exactly equivalent.

On this page