Haystack Tutorial (TypeScript): adding observability for beginners
This tutorial shows you how to add observability to a Haystack pipeline in TypeScript so you can inspect inputs, outputs, latency, and failures without guessing. You need this when your agent starts making real calls to LLMs and tools, because debugging by reading logs alone stops being enough.
What You'll Need
- •Node.js 18+ installed
- •A TypeScript project with
ts-nodeor a build step already set up - •Haystack TypeScript packages installed:
- •
@haystack-ai/core - •
@haystack-ai/openai
- •
- •An OpenAI API key
- •A logging destination:
- •local console for development
- •JSON logs or OpenTelemetry for production
- •Basic familiarity with Haystack pipelines, components, and connections
Step-by-Step
- •Start with a minimal pipeline that does real work. We’ll use a prompt builder and an OpenAI generator so there is something worth observing.
import { Pipeline } from "@haystack-ai/core";
import { ChatPromptBuilder } from "@haystack-ai/core/components";
import { OpenAIChatGenerator } from "@haystack-ai/openai";
const pipeline = new Pipeline();
pipeline.addComponent("prompt", new ChatPromptBuilder());
pipeline.addComponent(
"llm",
new OpenAIChatGenerator({
model: "gpt-4o-mini",
apiKey: process.env.OPENAI_API_KEY!,
})
);
pipeline.connect("prompt.prompt", "llm.messages");
- •Add structured logging around the pipeline run. This is the simplest observability layer: capture the inputs, measure duration, and record whether the run succeeded or failed.
type RunLog = {
name: string;
startedAt: string;
durationMs?: number;
ok: boolean;
error?: string;
};
async function runWithObservability<T>(
name: string,
fn: () => Promise<T>
): Promise<T> {
const started = Date.now();
const entry: RunLog = { name, startedAt: new Date(started).toISOString(), ok: false };
try {
const result = await fn();
entry.ok = true;
entry.durationMs = Date.now() - started;
console.log(JSON.stringify(entry));
return result;
} catch (err) {
entry.durationMs = Date.now() - started;
entry.error = err instanceof Error ? err.message : String(err);
console.log(JSON.stringify(entry));
throw err;
}
}
- •Pass request metadata through the pipeline input so every run can be correlated later. In practice, this means attaching a request ID and keeping the prompt content visible in logs.
const requestId = crypto.randomUUID();
const result = await runWithObservability("support-summary", async () => {
return pipeline.run({
prompt: {
template:
"Summarize this customer message for a bank support agent:\n\n{{message}}",
templateVariables: {
message:
"I was charged twice for card payment #48291 and need help reversing one charge.",
},
},
metadata: {
requestId,
userId: "cust_10492",
channel: "web",
},
});
});
console.log(JSON.stringify({ requestId, result }, null, 2));
- •Log component-level outputs after execution. This gives you visibility into what each stage produced, which is what you need when the final answer looks wrong but you do not know where it went off track.
const promptOutput = result.prompt?.prompt ?? [];
const llmOutput = result.llm?.replies ?? [];
console.log(
JSON.stringify(
{
requestId,
promptMessages: promptOutput,
replyCount: llmOutput.length,
firstReply: llmOutput[0],
},
null,
2
)
);
- •Wrap the same pattern into a reusable helper for all pipelines. Once this exists, every agent flow in your codebase can emit consistent observability data without copy-pasting ad hoc logs.
async function observedRun<T>(name: string, payload: unknown, fn: () => Promise<T>) {
const startedAt = performance.now();
console.log(
JSON.stringify({
event: "pipeline.start",
name,
payload,
ts: new Date().toISOString(),
})
);
try {
const output = await fn();
console.log(
JSON.stringify({
event: "pipeline.success",
name,
durationMs: Math.round(performance.now() - startedAt),
ts: new Date().toISOString(),
})
);
return output;
} catch (error) {
console.log(
JSON.stringify({
event: "pipeline.error",
name,
durationMs: Math.round(performance.now() - startedAt),
error: error instanceof Error ? error.message : String(error),
ts: new Date().toISOString(),
})
);
throw error;
}
}
Testing It
Run the script with a valid OPENAI_API_KEY and confirm you get three things in stdout: a pipeline.start event, a pipeline.success or pipeline.error event, and the final serialized result. If the pipeline fails, check whether the error is coming from missing API credentials, an invalid model name, or a bad input shape passed into run().
You should also verify that every request has a unique requestId, because that is what lets you trace one user action across retries and downstream services. If you are sending logs to something like Datadog or OpenTelemetry later, keep the same fields now so you do not have to redesign your schema.
Next Steps
- •Add OpenTelemetry spans around each component call so traces show timing across your full agent flow.
- •Emit token usage and cost estimates from LLM responses to catch expensive prompts early.
- •Store observability events in a central log sink instead of stdout once you move past local development.
Keep learning
- •The complete AI Agents Roadmap — my full 8-step breakdown
- •Free: The AI Agent Starter Kit — PDF checklist + starter code
- •Work with me — I build AI for banks and insurance companies
By Cyprian Aarons, AI Consultant at Topiax.
Want the complete 8-step roadmap?
Grab the free AI Agent Starter Kit — architecture templates, compliance checklists, and a 7-email deep-dive course.
Get the Starter Kit