Investigate
Skill misterbisson/timebuddy-incident-investigator/skills/investigate
Investigates a live or recent Grafana incident using the timebuddy-incident-investigator MCP tools - ingests alert details (a pasted Grafana URL, alert JSON, webhook payload, or just a description), replays the underlying queries over the incident window, checks against baselines, looks for correlated signals, pulls corroborating Graylog log evidence when a log connection is configured, and produces an evidence-linked verdict. Use whenever someone pastes alert/incident details, mentions being paged or on-call, or asks what's going on with a service.From its SKILL.md
npx -y skills add misterbisson/timebuddy-incident-investigator --skill investigateAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 1 stars1 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
40.7 KB, as published. Nobody here has run it
Investigate
Alert details, if any were included when this skill was invoked: $ARGUMENTS
This is the reactive path — something is (or was) firing and someone needs an answer quickly. Drive the tools below yourself; don't just describe what could be done. The person invoking this generally won't know the tool names or the right order to call them in — that's exactly what this skill exists to handle for them.
Pipeline
-
Normalize whatever was given. Call
get_alert_contextwith whichever ofurl,alertJson, orwebhookPayloadmatches what was pasted above (a Grafana dashboard/panel/ alert-rule URL, a raw alert JSON object, or a full Alertmanager webhook body). If nothing usable was given, ask for one of those three — a pasted Slack/email alert often has a URL in it even if the surrounding text doesn't look like structured data.- When the person points at what's firing right now rather than pasting one alert ("look
at what's firing", "the eu-central-1 alerts", "we're getting paged and I don't have a link"),
call
list_firing_alertsfirst — it reads Grafana's live Alertmanager state and enumerates the active alerts. PasslabelFilters(e.g.{"region":"eu-central-1"}or{"severity":"critical"}) to narrow a busy estate, andconnectionif they named the environment. Each entry comes back in the exact shapeget_alert_contextaccepts asalertJson, so once you've picked the relevant one, hand it straight toget_alert_context({ alertJson: <entry> })(or pass itssourcelink asurl) — no need to make the person go find and paste a link that Grafana already knows about. This is the resolution for the common "I directed you at the firing alerts but you couldn't see them" gap: there is a tool for it; don't ask the person to paste whatlist_firing_alertscan list. An empty result (and nofailedConnections) genuinely means nothing is firing on that connection right now — say so rather than assuming you looked in the wrong place. - Recognize stripped-link pasted alerts and ask, don't guess. Slack (and some email clients) turn a message's hyperlinks into plain anchor text when copied — you'll see link titles like "Dashboard", "Runbook", "Silence", "Graylog: APIGW 5xx" with no URL anywhere near them, alongside real-looking alert labels. This is NOT "truly nothing to paste" — a real link exists, it just didn't survive the copy. Say so plainly and ask for it directly (e.g. "Slack strips links when copied as plain text — could you grab the Dashboard or alert link itself, or the alert's timestamp?") before falling back to step 4's label/description search. Guessing the dashboard and time window from labels alone when a precise link was one click away wastes several tool calls and turns; asking up front is faster for everyone.
- Only skip straight to step 4 when the person genuinely has no link and no JSON to give you (e.g. they're describing the incident from memory, or pasted a screenshot's text by hand).
- If the response includes a
knowledgefield, a "Timebuddy knowledge" dashboard published product-specific context for this alert (owner, known false positives, runbook links) — fold it into your verdict in step 7. Absent means nothing's been published for this alert's product, not an error; don't go looking for it yourself (get_product_contextexists for that, butget_alert_contextalready tried). - When resolved from an alert-rule URL, the response may also include
ruleLastModified/ruleLastModifiedBy/ruleVersionandruleProvenance("none"= hand-edited in the Grafana UI, anything else = provisioned/managed elsewhere). If the rule was edited shortly before the incident started, that's worth calling out explicitly in step 7's verdict — "was this a recent config change or pre-existing config" is a common incident-review question this answers directly, without you having to ask the person to go check Grafana's UI by hand. Absent fields mean this Grafana instance/API version didn't return them (common on pre-11.5 Grafana), not that the rule has never been edited — don't read the absence itself as a finding.
- When the person points at what's firing right now rather than pasting one alert ("look
at what's firing", "the eu-central-1 alerts", "we're getting paged and I don't have a link"),
call
-
Note the resolved connection. The response includes
resolvedConnectionIdwhen it could determine which Grafana connection the alert belongs to. Pass that same id as theconnectionparameter on every tool call for the rest of this investigation — don't re-resolve it per call.- If
resolvedConnectionIdis missing andalertContext.warningsmentions being unable to determine the connection, the warning text lists the available connection ids — ask the person which environment this is (they usually know, even if the alert link didn't say) and use that asconnectiongoing forward.
- If
-
Get the primary signal(s).
- If you only have
alertContext.dashboardUidwith nopanelId— a bare dashboard link (e.g. someone pasted a/d/:uid?var-...URL with noviewPanel/panelIdin it) — callrender_dashboardwith that sameurlfirst. It executes every queryable panel on the dashboard in one call, using the url's own time window and variable overrides automatically. Don't manually chainfetch_dashboard-> guess which panel ->resolve_panel_queriesas a substitute for this — that's more tool calls to arrive at the same per-panel seriesrender_dashboardalready returns directly. Skimpanels[].series/stats(andtitle) for the panel(s) that actually answer the question, then drop into the steps below on just those panels for threshold/baseline analysis. - On a wide window and/or a dashboard with many panels, pass
includePoints: false. Every series'stats(min/max/mean/count/nonZeroCount) is computed and returned either way — only the rawpointsarray is dropped. A multi-day, all-panel render with points included routinely overflows the tool response to a saved file, forcing a jq/bash detour to recover numbersstatsalready had. Re-run the one or two specific panels you actually need points for (screenshot,execute_query_window'sthreshold, etc.) once you've picked them out from the compact survey. - Note each panel's own
titlefrom this response. You'll want it aspanelTitlein step 3 below on any dashboard where panel ids collide (see that step's note). - Table and matrix panels can look empty when they aren't. These are frequently built from
Grafana transformations (joins, filters, field overrides, computed columns) applied on top of
the raw query —
execute_query_window/render_dashboardonly ever return each query target's raw series, never the transformed/rendered result the panel actually displays. A raw series coming back empty or unremarkable does not mean the panel shows nothing; it may just mean the transformation that produces the interesting column isn't visible in the raw query alone. If you're asked to identify specific rows/cells in a table or matrix (e.g. "which hosts failed"), or a table/matrix panel's raw data looks empty or surprising, callscreenshot_panelon it and look at the actual rendered image before concluding there's no data — don't stop at raw series alone. The inline imagescreenshot_panelreturns is for you to look at — it is not guaranteed to reach the person you're talking to. Every call also saves the PNG to disk and returns its path assavedTo; always state that path in your response (e.g. "screenshot saved to <path> — open it to see the table") so they have something to actually look at, not just your description of what you saw in it. If you need the transformed data as data rather than a picture (e.g. to cite specific values in your verdict),export_panel_csvcan capture it directly — check itstransformationsAppliedfield;truemeans the file is Grafana's own transformed output, not just the raw per-query series. - Check
unresolvedAllVariablesbefore trusting a panel's scope.render_dashboard,resolve_panel_queries,execute_query_window, anddetect_correlated_anomaliesbest-effort resolve a$__all-selected variable to its real value list, but some datasources/query shapes can't be resolved this way and fall back to matching everything that datasource has ever recorded for that field — silently far broader than what the dashboard/panel actually shows. If a variable a panel depends on shows up inunresolvedAllVariables, treat that panel's results as unscoped: don't apply a naming-convention guess (e.g. matching a hostname prefix) to narrow down which rows are actually in scope — screenshot the panel or ask the person directly instead. - Once you have a specific
dashboardUid/panelId(fromalertContext, or picked out of arender_dashboardsurvey above): - Pass
panelTitlealongsidepanelIdon every call below whenever you already know it (fromalertContext.panelURL's resolved panel, or a priorrender_dashboard/fetch_dashboardcall) — don't wait for an ambiguity error to add it. Some dashboards have more than one panel sharing an id (a provisioning bug, not Grafana's repeat-panel feature);execute_query_window/validate_baseline/detect_correlated_anomaliesall reject an ambiguouspanelIdoutright rather than guessing, listing the candidate titles in the error — but that costs a whole extra round trip of retries you can just avoid up front by always passing the title when you have it. - Pass
includePoints: falseonexecute_query_windowwhen you're re-confirming somethingstats/runsalready told you and don't need the raw series — same tradeoff asrender_dashboard's option above, for the same reason.validate_baselinehas no such parameter, and passing it there is silently ignored rather than rejected (zod strips unknown keys), so you'd believe you'd asked for a compact response and get the full one — which is the response-overflow problem in step 3, not a fix for it. To keep avalidate_baselineresponse small, narrow the window instead. execute_query_windowwithdashboardUid,panelId,startsAtMs(usealertContext.startsAt),connection— this gets you the incident window, a pre-window buffer, and baseline control windows in one call. Every series in the response already includesstats(min/max/mean/count/nonZeroCount) — check that first for quick questions like "was there any traffic at all" or "what's the min/max here" instead of writing jq/python over a saved tool-output file to compute the same thing yourself, even for a substitute panel you fall back to (e.g. because the primary panel errors or times out) — this isn't just for the one panel the alert points at. If you know or can guess a meaningful threshold for the metric (e.g. an uptime-style series where 1.0 = fully healthy, a known SLO threshold, or 0 for "any activity at all" on a volume/count metric), passthreshold/thresholdDirectionin the same call — it returns each series' precise dip/spike windows (start, end, duration, min/max) directly, including whether sibling series in the same panel (other hosts/cells/nodes) show the same dip. Do this instead of fetching raw points and writing jq/python to find dip boundaries yourself — that's exactly whatthresholdis for, and scripting it ad hoc from a saved tool-output file is slower and more error-prone mid-incident.- A query against an InfluxDB-backed panel that times out or aborts is often hitting
InfluxDB's own hard ~15s query timeout, not a transient fluke — this server's own default
request timeout is coincidentally the same ~15s, so a heavy query (a fine-grained
GROUP BY time(5s)-style aggregate over a wide window is the usual culprit) can fail on either side of that race. Don't just retry the identical call, and especially don't retry several of them in parallel expecting one to get through — that adds load and makes the timeout more likely, not less. Instead narrow the window, drop to a coarser aggregation, or fall back to a cheaper/ non-aggregated target for that same series, and prefer calling sequentially over an InfluxDB-backed datasource rather than in parallel once you've seen one timeout. validate_baselinewith the same panel/window to get a real classification (statistically-unusualvscommon-during-normal-operations) instead of eyeballing numbers. Always check each series'briefExcursionstoo, even whenclassificationsays common — that classification is based on the whole window's mean, which can dilute a real, sharp, short-lived event (e.g. a health signal fully down for a few minutes inside a much longer window) into looking routine.briefExcursionsis a separate, point-level check against the same baseline and will still catch it. Don't just trust a "common" label at face value.classification: baseline-all-zeromeans there is no baseline to compare against. Every control window was flat zero and the incident window isn't — something that never happened is happening. That's real information, but it is not a statistical result:zScorecomes back asnull(there's no meaningful value to report, and JSON has noNaN), and no number could make the finding statistically strong anyway, because there's no spread to be strong relative to. AnullzScorehere means "not applicable", not "zero" and not "missing data" — that'sinsufficient-data, a different classification. Report it as a presence change, cite the actual numbers fromincidentStats(peak, nonzero sample count) rather than any sigma figure, and lean on corroboration — did the alert's own threshold cross, did anything correlate — to decide whether it matters. Don't describe it as "highly unusual" or similar; you don't know that.- A
runsTotalorbriefExcursionsTotalfield means the list next to it was truncated. Both appear only when a series produced more than 1000 threshold crossings, and both report the real count. Don't read the truncated list as the complete set, and don't reason about "the first crossing" from it — a series that noisy is telling you the threshold or the window is wrong for it, so re-query a narrower window (or a coarser aggregation) and look again rather than summarizing what came back. - A run's
durationMsis the span between its first and last crossing sample, not a bucket-aware outage length — don't report it as the exact length. A dip caught in a single sample hasdurationMs: 0(start and end are the same timestamp); that is "one bucket wide," not instantaneous or a blip — a one-minute outage on a 60s-resolution series looks exactly like this. Every run also understates the real duration by up to one sample interval, because the final sample's own bucket isn't counted. ReaddurationMstogether withpointCountand the series' sample spacing (the gap between adjacent points): apointCount: 1run lasted at least one interval, and "N samples at ~S apart" is the honest way to describe how long a dip persisted.
- If you only have
-
If there's no dashboard/panel link (warnings said so, or there was nothing to paste in step 1), fall back to
find_related_dashboardsusingalertContext.labels(or whatever labels the person gave you) to locate relevant dashboards. If the only thing you have is a product/ service name in plain language (e.g. "block storage is degraded") rather than an exact metric or label value, usefind_related_dashboards'squeryparam instead — a free-text substring match against metric names and dashboard/panel titles. Then proceed as in step 3 once you have a candidate panel.- If
find_related_dashboardscomes back empty for a specific metric/measurement name you have independent evidence should exist (it's named in the alert itself, an error message, or a log line — not just a hunch), and the environment is InfluxDB-backed,discover_influxdb_schemais a last-resort fallback: it queries the datasource's own schema directly rather than only what's been dashboarded. Use it only in that specific situation, not as a routine step or a first move — pass the exact name assearchTerm.find_related_dashboardscoming back empty just means no dashboard visualizes it yet; that's itself worth reporting (a real observability gap on the dashboard-owning team's side), separate from whether the metric exists at all. - If
execute_adhoc_queryis in your tool list, it can run a query you write yourself rather than one from a dashboard. It's absent unless the workspace explicitly authorized it, so its presence means someone deliberately turned it on here — usually a repo where dashboards are authored as code. Treat it as a later resort thandiscover_influxdb_schema, not an easier alternative to the steps above:- Reach for it only after
find_related_dashboardsandresolve_panel_queries/discover_influxdb_schemahave come up empty, or when the task is explicitly to iterate on a query destined for a dashboard. - A dashboard query encodes aggregation, retention-policy, and field choices that a service
owner made and validated. A query you write can look right and be subtly wrong —
mean()over a counter, the wrong retention policy — andvalidate_baselinewill compute a perfectly confident z-score on it regardless. Nothing downstream can detect this for you. - Its results carry
provenance: "adhoc". Pass that through on the matchingevidenceentries in step 7, and say so in the written note. A verdict resting on queries nobody validated must not read like one resting on a panel a team maintains. - Every call leaves a Grafana Explore URL in the audit trail that replays it exactly. Include that URL in your evidence so a human can check your query rather than take it on trust.
- Reach for it only after
- If
-
Check blast radius, once you have a primary panel:
detect_correlated_anomalieswithprimaryDashboardUid/primaryPanelId/startsAtMs/primaryLabels/connection— omitcandidatesto let it auto-discover across the metric index. This tells you what else moved around the same time, which is often the actual answer to "is this real."Call it once per
scopetier, narrowest first, and report each tier's result before widening — don't just callall-connectionsstraight away. Default (scope: "product") checks the primary dashboard plus whatever its own Timebuddy knowledge panel declares as its ops/SLI dashboards and dependencies (falls back to just the primary dashboard alone if no knowledge is published for this alert — checkproductScope.sourceto know which happened). Say what that tier found — even "nothing correlated within the product" is a real, useful thing to report mid-investigation, not just a step to silently pass through. Then look atnextScope/nextScopeCandidateCountin the response: ifnextScopeCandidateCountis0, there's nothing to gain by widening, say so and move on. IfnextScopeCandidateCountis missing instead of a number, it's genuinely unknown (not zero) — widening toscope: "connection"/"all-connections"involves crawling every dashboard on a connection the first time (or once its cache goes stale), which this tool only ever does for connections the requested scope actually needs, so it won't pay that cost just to answer a count you didn't ask for. Otherwise call again withscope: "connection", report that, and only escalate toscope: "all-connections"— the broadest and most expensive tier — when you still don't have a confident answer. Skipping straight to the widest scope costs several times the query volume for no benefit when the narrower tier would have already answered it.Finding correlated panels within
productscope does not establish that the blast radius is contained — it only tells you the product moved, which you already knew. "Contained to the product", "nothing else in the region was affected", "no other product moved" are all claims about theconnectiontier (every other dashboard on the same Grafana — i.e. the other products in that region/estate), and you cannot make any of them fromproduct-scope results alone. This is the resolution for the "never checked other products in the region" gap, and it is not a knowledge-dashboard gap:connectionscope sweeps the whole region regardless of whether a Timebuddy knowledge dashboard is published. It bites hardest whenproductScope.sourcecomes backsame-dashboard-only(no knowledge published for this alert) — there,productscope only ever looked at the primary dashboard itself, so a "clean" or "self-contained" product-scope result has quite literally not looked at any other product yet. So before you assert containment or that the blast radius is confined to the product, runscope: "connection"(unless itsnextScopeCandidateCountwas0, meaning there's nothing else on the connection to check). Report what it found — "checked the N other dashboards on the region connection, nothing else moved" is a real, earned containment statement; "the same-dashboard panels agreed with each other" is not. Don't wait to be asked "what about other products?" — that check is part of establishing blast radius, not an optional follow-up. The tool now flags this for you: acontainmentCheckIncomplete: truein the response (with acontainmentHintnaming how many same-connection panels are still unchecked) means you are not done — re-run withscope: "connection"before writing any "contained"/"self- contained"/"no other product affected" language into the verdict.This only checks other dashboards — it won't catch a primary panel that's a narrow/synthetic signal (e.g. a health-check-style series) overstating impact that sibling panels on the same dashboard would reveal. Especially for an undirected "find errors"/"what's going on" investigation with no specific alert pointing at one exact panel: before asserting severity from a single high-deviation panel, check whether the same dashboard has sibling panels measuring the same underlying thing at a different, more concrete grain — real traffic/volume, per-account or per-method breakdowns, success-rate panels — over the same incident window (
execute_query_windowor a quickrender_dashboardre-check withincludePoints: falseis usually enough). A panel reading "0% for 5 hours" next to real traffic that only dipped 4% and was concentrated on one account is a materially different, more useful finding than the first panel alone.A stat panel using Grafana's built-in "-- Dashboard --" datasource re-displays another panel's already-computed value client-side and has no backend to query, so it can never be executed. How that reaches you depends on which tool you called, and it matters here because
detect_correlated_anomaliesis one of the tools that throws:- Reported gracefully by
render_dashboardandresolve_panel_queries, asmirrorsPanelIdson the panel — the rest of the dashboard still comes back normally. - Thrown as an error by
execute_query_window,validate_baseline,export_panel_csv, anddetect_correlated_anomalieswhen the mirror panel is the one you named. The message names the mirrored panel id(s), so call that panel directly and move on. - Silently omitted when
detect_correlated_anomalieshits one among the candidates it auto-discovered, rather than as the primary panel. Those run underPromise.allSettledand rejected candidates are skipped, so the mirror panel is neither returned nor reported — it just isn't in the results. Don't read a candidate's absence as evidence it was checked and cleared.
All three are expected, not failures to investigate. Don't go looking for a misconfiguration and don't treat the throw as the dashboard being broken — it's this Grafana feature working as designed.
If a panel's queries fail with something like "404 Data source not found" for any other reason, check whether its datasource reference is a literal name rather than a UID (not a
$variable— those are already handled).list_datasourcestells you whether a datasource matching that name still exists under a different UID — if it does, that's a real, reportable finding ("this dashboard's datasource reference is stale, here's the correct UID"), not a dead end. If nothing close shows up, say so plainly rather than retrying — that dashboard needs a Grafana-side fix, not something any tool call here can resolve. - Reported gracefully by
-
Pull corroborating log evidence, once you have a primary panel and/or some identifiers. This step is not optional and is not conditional on how the metrics looked. Always run it — calling
list_log_sourcesis a required part of every investigation, and the only clean way to skip the rest of the step is an emptysources(no Graylog configured). A confident, clean-looking metrics verdict is not a reason to skip logs — it is exactly when logs pay off most. Metrics tell you that a signal moved and where (which host/orchestrator/cell); logs routinely tell you why, turning "chronic CM errors ona2" into "one specific certificate stuck in a non-exportable state." Don't jump from step 5 to step 7 because the numbers already tell a tidy story; pull the logs and let them sharpen (or contradict) it first.- Call
list_log_sourceswith no arguments. Ifsourcesis empty, no Graylog connections are configured — skip the rest of this step silently; that's a normal, common state, not something to flag as missing unless asked. Any other outcome means you keep going. - Pick which log connection to use. If
sourceshas exactly one entry, use itsidasconnectionon the calls below — no further resolution needed. If there's more than one, pair by tags instead of asking outright: calllist_datasourceswithconnection: <resolvedConnectionId>(the Grafana connection from step 2) to getconnectionTags[<resolvedConnectionId>], then find the log source(s) insourceswhose owntagsarray shares at least one value with it. Exactly one match: use it. Zero or more than one match (or noresolvedConnectionIdto pair from): ask the person which log connection covers this environment, listing the candidate names — same "ambiguous is a hard stop, don't guess" approach every connection resolution in this server already follows. - Gather identifiers to search on from what you already have:
alertContext.labels, thelabelsfield on any series you pulled in step 3 (execute_query_window/render_dashboard/detect_correlated_anomaliesall return per-serieslabels— hostnames, instance ids, whatever the underlying datasource query tagged each series with), and the labels on any correlated result from step 5. Only use identifier values that are actually present in this data — don't invent a hostname or guess at one from a naming convention.- If your primary panel aggregates across hosts (its series carry no host/instance label,
so nothing above yields one), you can enumerate the real candidate values rather than
inventing one — how depends on the datasource:
- InfluxDB: call
discover_influxdb_schemawith the measurement assearchTermand the host/instance tag astagKey. Itsschema.tagValuesis the actual host/IP list for that measurement. - Prometheus or Loki (or InfluxDB too): call
discover_label_valueswith themetric(the Prometheus metric name / series selector, Loki stream selector, or InfluxDB measurement) and thelabelkey (e.g.instance/pod/host). Itsvaluesis the actual identifier list — thelabel_values(metric, label)equivalent. Either way it's a real, data-derived set you can legitimately scope a log search to (it just doesn't by itself say which host was hot). Same last-resort framing as step 4: only when you'd otherwise have no identifier. If either call returns an empty list, don't read that as "no hosts" straight away — a mistypedlabel/tagKey/metricisn't an error to the datasource, it just matches nothing, so re-check the name against the panel's own series labels (ordiscover_influxdb_schema'stagKeys) before concluding the set is truly empty.
- InfluxDB: call
- To find which of those hosts was actually hot (not just the candidate set), re-run the
aggregating panel broken out per host: call
execute_query_windowwithtagBreakout: { key: "<host tag>" }(the same tag key you enumerated above). It adds aGROUP BY/by (...)for that tag, so the panel returns one series per host instead of one aggregate — read each series'stats/runsto see which host drove the anomaly, then use that host's name as thesearch_logsidentifier. To confirm a single suspected host in isolation, passtagBreakout: { key, value: "web-07" }to filter to just it. This works on builder-mode InfluxQL and PromQL panels; a raw-query InfluxQL or Loki/LogQL panel hard-errors (it won't silently return the un-broken-out aggregate), so fall back to the candidate-set search above in that case.
- If your primary panel aggregates across hosts (its series carry no host/instance label,
so nothing above yields one), you can enumerate the real candidate values rather than
inventing one — how depends on the datasource:
- Call
search_logswith those identifiers built into Graylog query syntax (e.g.host:web-03 AND level:ERROR), the samestartsAtMs/endsAtMsas the incident window, and the resolved logconnection. Graylog's own field names for a given identifier aren't knowable in advance from this side — if a specific query comes back empty, try a broader one (fewer fields, or a bare*scoped to the time window) before concluding there's nothing there, and say so plainly if it's still empty rather than treating silence as a finding. - If that call (or
correlate_logs, below) fails with a permission error mentioning "no stream filter applied" or similar, the connection's Graylog role can search within a stream but not across all of them unscoped, and it has no default stream configured — this is a real, common lockdown, not a broken connection. Calllist_log_sourcesagain withconnection: <resolvedConnectionId>to list that connection's streams, pick the one most relevant to what you're investigating (orDefault Stream/All events/All messagesif nothing more specific fits), and retry the same call withstreamIdset explicitly rather than reporting the connection as broken. - If you have a specific join key connecting two log streams (e.g. a
request_idtying a frontend log line to the backend call it triggered), usecorrelate_logsinstead of two separatesearch_logscalls — one query, one already-joined result set. Itsand/or/unlessoperators are documented in the tool description;unlessis the one worth remembering for "which requests on one side never showed up on the other."- A
truncated: truein the result (or anystreams[]entry withtruncated: true) means a stream hit the per-stream line cap, so the join ran on a partial view — treat the result as a floor, not a complete count, and narrow the window/query if the count matters. A truncatedunlessright side errors out rather than returning a possibly-inverted answer; narrow and retry rather than working around it.
- A
- If the log evidence stands on its own — a service, host, or error type that showed up in the
logs but that you never reached from a Grafana panel (no primary panel in step 3, or the log
finding names something the panel you did have doesn't cover) — pivot back to
find_related_dashboardsbefore summarizing, with the identifier pulled from the log line as itsqueryparam (a service/host/error keyword — the same free-text substring match against metric names and dashboard/panel titles it does in step 4, just seeded from a log finding instead of an alert). A hit gives you a metrics panel that corroborates (or contradicts) the log finding, which is stronger evidence than the logs alone and belongs in the verdict the same way any other panel would; baseline or blast-radius-check it as in steps 3–5 if the window warrants. Report the empty result explicitly when nothing matches — "no Grafana dashboard visualizes <identifier>" is a real finding (the log signal isn't dashboarded, an observability gap worth naming, same as the empty-find_related_dashboardscase in step 4), and stating it keeps a log-only verdict from reading as if you simply forgot to check the metrics side. - Whatever
search_logs/correlate_logsreturns is one more piece of evidence for step 7's verdict, not a separate report — itsurlbelongs inevidence[]the same way every other tool's URL does, and a specific log line or correlated pair worth citing belongs in the verdict's prose the same way a baseline number would. - Every log claim you make must be traceable to the exact search that produced it — surface
that search's own returned
url, one link per distinct search. Eachsearch_logs/correlate_logscall returns aurlthat encodes that call's query, time window, andstreamIdexactly, so it re-runs to the same result set someone else can read. When you cite a count, a cert ARN, a host, or an error string, link theurlof the search that actually returned it — not the broad*/single-bare-term fallback search you used along the way to find the right query, and not one "representative" link standing in for several searches. If several searches back the finding (e.g. one for the incident-window count and one over a wider window), include each one'surlas its own evidence entry so the query and window on each link match the specific claim it supports. A verdict that asserts a precise root cause but links a vaguer search (or no search) reads as unverifiable even when it's correct — the whole point of the link is that the reader can click it and see the same lines you did. - This link discipline applies whenever you report log findings — including when logs are
pulled reactively, after you've already given a metrics verdict (someone asks "what did you
find in Graylog?" and you go search). You're not going back through
summarize_findingsin that case, but the same rule holds: name each search's ownurlnext to the claim it supports, and never hand-build or merge a Graylog URL (see step 7). Don't let a follow-up log dig skip the evidence links just because it's outside the one-shot pipeline.
- Call
-
Assemble the verdict:
summarize_findingswith the baseline result, correlated results, and anevidencearray of dashboard/panel links you gathered along the way. Mark any entry backed byexecute_adhoc_querywithprovenance: "adhoc"— that adds a disclosure caveat tomissingDatawhich belongs in your written note; omitting it presents a hand-written query as though a service owner had validated it. Passvalidate_baseline'sbriefExcursionsthrough inbaselineunchanged, not just the top-levelclassification—summarize_findingsnow folds that in itself, so it never returnslikely-false-positivewhile brief excursions are sitting right there unexamined (it returnsinconclusiveinstead when the window-mean says "common" but excursions say otherwise). This used to be entirely on the calling agent to catch by eye, per thebriefExcursionsguidance in step 3 above — passing the array through now makes that check real instead of easy to forget. Every tool above (get_alert_context'sdashboardUrl,execute_query_window/validate_baseline'surl,detect_correlated_anomalies'sprimaryUrland each correlated result'surl,find_related_dashboards's per-matchurl,search_logs/correlate_logs'surl) already returns a ready-to-click link at the exact panel/query and time window — use those directly asevidence[].url. Never construct or paraphrase a URL yourself, for a Grafana dashboard or a Graylog search alike (e.g. by guessing at/d/{uid}, copying a base URL from somewhere else, or combining the query text from two differentsearch_logscalls into what looks like a single representative link) — the tools already know the right connection's base URL and the right query params; hand-building or merging one risks pointing at the wrong instance, a broken query string, or a link missing required params (e.g. Graylog's absolute time range) that only the tool's own URL builder fills in correctly. If you want to cite more than onesearch_logscall in the verdict, include each call's ownurlas a separateevidence[]entry rather than folding them into one.summarize_findingsreturns structured data only — no prose. Reading itsreasonsandevidencefields, write the actual human-readable incident note yourself: what fired, whether it's real, why (cite the specific baseline/correlation numbers), and the links you collected so the person can click straight through and verify anything you said. This last step is not optional — a bare JSON dump back to a NOC person on a live incident is not useful in the flow. If you calledscreenshot_panelanywhere above, list each screenshot'ssavedTopath next to the panel it came from — it's evidence for "why do you say that," and the file is the only reliable way the person actually gets to see it (see step 3 above). Ifget_alert_contextreturned aknowledgefield (step 1), weave its content into the note (e.g. a known false positive, an owning team, a runbook link) rather than leaving it as a separate, unexplained blob.
If asked to archive, export, or hand off a panel's data (not just describe it) — for a
postmortem, a report, or further analysis in another tool — use export_panel_csv on that panel
rather than pasting numbers by hand. It writes a real CSV file to disk and returns its path; state
that path in your response the same way you would a screenshot_panel savedTo path. Table panels
export as-is; timeseries/graph panels come out as one UTC-timestamp column plus one column per
series. This is a follow-up action once someone has a specific panel in mind, not a replacement for
execute_query_window's stats/threshold analysis during the investigation itself.
Never read this server's own persistent, unredacted data store directly (metric-index.json,
connections.json, or anything else under its data directory), even if you can find where it's
stored on disk — always go through the MCP tools above. That data was never redacted for you to
see; a tool's own return value always is. If a tool doesn't seem to cover what you need, that's a
sign to use a different tool/param (query for free-text search, connection to scope a search),
not to go around the tool layer, especially mid-incident when speed matters and a wrong shortcut is
expensive.
This is a different thing from a tool response the harness itself saved to a file because it was too
large to return inline (you'll see this for a wide-window render_dashboard/execute_query_window
call) — that file is the same already-redacted output you'd have gotten inline, just spilled to
disk for size reasons. Reading it back, or running jq/grep over it to pull out specific fields
without loading the whole thing into context, is fine and often the right move for a large survey —
just prefer includePoints: false up front (step 3 above) so you don't need the detour in the first
place.