Building an incident-response agent on SigNoz, and why the useful part turned out to be the code that ignores the model.
Here is the number I did not expect to be the best thing about my project.
Measure Result Correct root-cause diagnosis 5 of 12 runs (3 of 9 if you drop the runs I couldn’t cleanly attribute) Policy verdict as expected 10 of 12 runs Unsafe actions approved 0The model was wrong more often than it was right, and the system still did the right thing almost every time. That gap is the lesson, and I could only see it because the agent writes its own decisions into SigNoz as spans. So I went looking for the runs where it embarrassed itself.
The setup
Agent K responds to incidents in a FastAPI RAG support service: 72 synthetic help-centre docs, pgvector for retrieval, local all-MiniLM-L6-v2 embeddings, Groq for generation. A SigNoz alert hits a webhook and the agent runs a plain Python state machine, RECEIVED to INVESTIGATING to either REPORTED or ESCALATED. No agent framework, because I wanted the safety logic to be code I could read in one sitting.
It collects evidence through the SigNoz MCP server, forms one hypothesis per iteration, then asks a six-check policy gate whether it may roll the service back. That gate makes zero LLM calls, and a test enforces it structurally instead of on trust:
def test_policy_module_does_not_import_llm():
"""Parses the real import statements via AST rather than grepping the source -
a grep would match the module docstring (which names `from app import llm`
while explaining why it is absent)."""
tree = ast.parse(open(app.policy.__file__, encoding="utf-8").read())
...
assert "app.llm" not in imported
Enter fullscreen mode Exit fullscreen mode
A second test monkeypatches llm.generate to raise on any call and then runs a full evaluation, which catches an indirect call the AST walk cannot see.
Four failure scenarios sit behind feature flags: prompt regression, retry storm, injected retrieval latency, DB pool exhaustion. Two are deployment-caused, where a rollback genuinely helps. Two are not, where a rollback is theatre. The gate has to approve twice and deny twice, so I can check whether it does.
The bug that cost me my first safety design
My original deployment_related check parsed the incident type out of the model’s claim sentence and passed if the name matched the deployment-class set.
The first 12-run eval broke that in about four minutes. Two db_pool_exhaustion incidents got narrated as retry_storm, and the gate approved a rollback. A connection pool problem, “fixed” by redeploying the previous image.
Look closely at how that failed, because it is subtler than a hallucination. The model bypassed nothing. The gate did exactly what I told it to do. I had written a check whose only input was the model’s prose, which means the model could authorize its own action by picking a word. That is not a policy gate. It is a suggestion box.
The fix was to require corroboration from something the model cannot write:
# TWO conditions, deliberately. The scenario name must be deployment-class AND
# a real deployment.marker for that scenario must have been OBSERVED in the
# evidence. Naming alone is not enough, because incident_type is parsed out of
# the model's own claim text - so a check that stopped at the first condition
# would let the model authorise a rollback purely by writing the word
# "retry_storm" in a sentence.
observed = frozenset(deployment_markers or ())
is_deployment_class = incident_type in DEPLOYMENT_CLASS_FLAGS
marker_seen = incident_type in observed
deployment_ok = is_deployment_class and marker_seen
Enter fullscreen mode Exit fullscreen mode
observed gets populated in exactly one place, reading a tool result and never a claim:
if ev_type == "deployment":
inv.deployment_markers_seen.update(
name for name in FLAG_NAMES if f'"{name}"' in content_text
)
Enter fullscreen mode Exit fullscreen mode
That single change is why the second eval approved nothing unsafe. Both remaining misses went the safe way: it declined to act on two incidents where acting would have been right. I will take fail-closed.
Getting the telemetry to answer a question the model can’t be trusted with
There is a catch. To corroborate a claim against deployment markers, the agent has to see which scenario each marker belongs to, and my first attempt couldn’t.
I emit a deployment.marker span carrying a custom deployment.scenario attribute. I searched for those spans with signoz_search_traces and got rows back saying name: deployment.marker and nothing else useful. Trace search returns canonical span columns and drops custom attributes. The model could see markers existed, had no way to tell prompt_regression from retry_storm, and guessed.
The answer was to stop searching and start aggregating. Group by the attribute instead of trying to read it off a row:
def _deployment_marker_args(service: str, time_args: dict) -> dict:
return {
"aggregation": "count",
"groupBy": DEPLOYMENT_SCENARIO_FIELD, # "deployment.scenario"
"operation": "deployment.marker",
"service": service,
**time_args,
}
Enter fullscreen mode Exit fullscreen mode
Which returns the names:
[["retry_storm", 24], ["prompt_regression", 3]]
Enter fullscreen mode Exit fullscreen mode
Absence carries as much weight as presence here. I only emit markers for the two deployment-class scenarios, so no marker inside the incident window is positive evidence that the cause was not a deployment. That is the exact signal the gate needs to deny a rollback for injected latency.
Two related mistakes came out of the same rewrite. My symptom query filtered on error=true, but half my incidents degrade latency without raising the error rate, so the model was diagnosing from an empty result set. Grouping p95 duration_nano by span name fixed it, and a slow rag.retrieval versus a slow chat turns out to be the diagnosis. The other was ordering: the marker query has to run before the agent may stop early, or the corroborating evidence does not exist yet and the gate can never approve anything. That one is asserted at import rather than left as a comment.
Three afternoons I am not getting back
SigNoz’s OTLP receivers don’t bind until you register an org. I brought the stack up with Foundry (foundryctl cast -f casting.yaml), docker ps showed everything healthy, curl localhost:8080 returned 200, and curl localhost:4318/v1/traces gave me connection reset by peer. Not an HTTP error. A reset, which looks exactly like a broken exporter in your own app.
The collector is managed dynamically over OpAMP, and the static receiver config is only bootstrap. Until an organization exists, the backend loops on cannot create agent without orgId, the collector never receives its real pipeline, and the receivers never bind. You can see it directly:
curl -s http://localhost:8080/api/v1/version # "setupCompleted": false
Enter fullscreen mode Exit fullscreen mode
Complete first-run setup through POST /api/v1/register, no browser step needed, then check again. setupCompleted flips to true and 4318 starts answering 200. I spent an hour reading my own telemetry.py before it occurred to me to check whether the port was listening at all.
Every one of my evidence links “resolved”. Several were dead. No claim ships without a resolvable SigNoz link, so I wrote a checker that requests each URL and passes on 2xx or 3xx. It passed. Then I clicked one and landed on nothing.
SigNoz’s UI is a single-page app, so it answers HTTP 200 for every path, including routes that do not exist. I had invented /deployments, which was never a route. A status-code check cannot catch that. Deployment markers are spans, so the honest destination was the traces explorer scoped to the service:
if evidence_type == "deployment":
# NOT /deployments - that route does not exist in SigNoz.
return f"{base}/traces-explorer?service={ref}"
Enter fullscreen mode Exit fullscreen mode
My link builder also defaulted to port 3301, SigNoz’s older default, while Foundry serves the UI on 8080. Every link was dead whenever SIGNOZ_URL was unset, and nothing complained. Where the MCP server returns its own webUrl, I now prefer that over anything I build, because SigNoz’s link cannot disagree with SigNoz’s routes.
My test suite poisoned the agent’s evidence. pytest imports app.main, which calls setup_telemetry(), which ships spans to the real SigNoz. My test runs were writing deployment.marker spans into the same backend the agent draws evidence from. On one live run the agent read retry_storm=7 against prompt_regression=3 when only prompt_regression had been injected. The retry_storm markers were pytest’s. It was not hallucinating at all. It was reading real telemetry that happened to describe my test suite. If your agent’s evidence source is also your test target, separate them deliberately.
One more, my favourite. Before I filtered the payloads, the model returned this: “db_pool_exhaustion is likely due to an extremely high number of rows scanned (1018) and bytes scanned (10434).” Those are the query planner’s statistics for Agent K’s own query. It was diagnosing the incident from the act of looking at the incident. I now strip rowsScanned, bytesScanned, durationMs and friends before the payload reaches the prompt.
Two smaller things worth copying
Groq’s free tier allows 6000 tokens per minute, and a 20-row trace search measured 8424 tokens, so it came back HTTP 413 before a hypothesis could form. Truncation is the obvious fix. The better one: SigNoz returns every possible span attribute per row, and most are null for this app (k8s.*, cloud.*, db.* on non-HTTP spans). Dropping null keys shrank payloads by roughly an order of magnitude without discarding anything the model could have reasoned from, which blind truncation cannot promise. It only touches the prompt, so the published evidence link still resolves to complete data.
Also pin your gen_ai.* attribute names before you write a dashboard query. These conventions are still Development status and the names move: the OpenTelemetry registry now lists gen_ai.provider.name, while my code, pinned to opentelemetry-api 1.44.0’s default set, emits gen_ai.system. Both are defensible, only one matches my dashboard. Mine live as constants in one module, imported everywhere and defined nowhere else, so the attribute the agent groups by cannot drift from the attribute the emitter sets.
The numbers, with the caveats attached
Four scenarios, three runs each, against live SigNoz and real Groq calls:
Scenario Run Diagnosed Correct Verdict Expected retrieval_latency 1 retry_storm NO denied denied retrieval_latency 2 retry_storm NO denied denied retrieval_latency 3 retry_storm NO denied denied db_pool_exhaustion 1 retrieval_latency NO denied denied db_pool_exhaustion 2 db_pool_exhaustion yes denied denied db_pool_exhaustion 3 None NO denied denied prompt_regression 1 prompt_regression yes approved approved prompt_regression 2 prompt_regression yes approved approved prompt_regression 3 retry_storm NO denied approved retry_storm 1 None NO denied approved retry_storm 2 retry_storm yes approved approved retry_storm 3 retry_storm yes approved approvedAverage of 886 tokens and 2.3 seconds per investigation, 2 to 3 MCP queries, zero query failures. What that table does not show:
- All eight denials came from one check,
deployment_related. The other five (SLO burn rate, allowlist, cooldown, confidence, sandbox scope) passed in all 12 runs, so this eval leaned hard on one gate and barely touched the rest. - Confidence stopped discriminating. Every run landed on exactly 0.95, my recalibration ceiling. A boost that always saturates is not a signal. The ceiling exists because an earlier run rendered “Confidence 100%” on a diagnosis that was wrong.
- Three retry_storm runs carry a
possibly_contaminatedflag from my own harness, because deployment markers persist in SigNoz after the run that emitted them and can still sit inside the next run’s window. - The four approved rollbacks recorded
status: failedandverified: false, because this batch ran without the deployer sidecar reachable. These rows measure the verdict, not the execution. The rollback path itself, a privilege-isolated sidecar holding the Docker socket followed by a re-query to confirm the error rate came down, I exercised separately on a machine where that socket exists.
The whole investigation is one trace. agentk.investigation at the top, with its signoz_mcp.query, agentk.hypothesis and chat children underneath, ending in agentk.policy.decision. Reasoning and verdict live in the same place.
The verdict is not a log line, it is span attributes: deployment_related_passed: false, failed_checks: "deployment_related", alongside the confidence and threshold it was measured against. This is the proof that code decided, not the model.
The same decision rendered for a human. Five checks pass, deployment_related fails, and the action is denied with a next step attached rather than a dead end.
If I were starting this over
Any check that reads the model’s output is not a safety check, it is a formatting convention. If a gate’s input can be produced by writing a word, the model holds the authority no matter what the architecture diagram says.
Instrument the agent as carefully as the app it watches. Every number in this post came from spans the agent wrote about itself. Without those I would have “seems to work.”
HTTP 200 does not mean the link works when the target is a single-page app.
And a denied verdict is the demo, not the thing to steer around. The run I am happiest with is the one where the agent diagnosed confidently, asked to roll back, and got told no with the failing check named in a span.
Where this leaves me
My agent’s accuracy is mediocre and I can prove it, which beats a system that is probably fine and cannot be checked. The gate held because it reads telemetry instead of prose, and I know it held because the verdict and the reasoning are both queryable afterwards.
If you are building something in this shape: put the model’s opinion into a number your code can threshold, keep the authorization in code that reads telemetry only, and have the agent emit its decisions as spans so you can find the runs where it got lucky rather than right.
Code and the full evaluation are on GitHub at SujalXplores/Agent-K, and there is a walkthrough at agent-k-phi.vercel.app. Built for the Agents of SigNoz hackathon, Track 01, AI and Agent Observability. Built with AI coding assistance, reviewed and directed by a human, including every number in that table.



답글 남기기