You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Datasource health checks (the CheckHealth behind Save & test) return inconsistent, often raw or opaque error messages across the ~50 datasource plugins.
This RFC proposes a small shared library — dsconfig/health — that normalizes every health-check failure into a consistent, safe, machine-classified, and actionable result:
a stable, machine-readable errorCode,
a clean, secret-free human message,
structured remediation that is context-aware across many dimensions (not just auth type), and
a redacted verbose detail for support.
The core module depends only on grafana-plugin-sdk-go, so HTTP-only plugins incur no SDK bloat. Most plugins adopt it via a dependency bump, because the logic plugs into the shared CheckHealth paths already provided by sqlds and grafana-aws-sdk.
2. Motivation & background
CheckHealth is the first real interaction during onboarding; a confusing failure here is a major drop-off point and a top support driver. Problems today:
Inconsistent shape — some plugins return err.Error() raw, some fmt.Sprintf a prefix, some a hardcoded generic string. No common structure across repos.
Unsafe content — raw pass-through can leak hosts, DSNs, and tokens into a UI/log string.
Unactionable — messages rarely say what to fix or where, and almost never link to docs.
Headline: ~30 plugins use an SDK/driver; ~20 use a direct HTTP connection.
Current error-message patterns (~25 sampled): raw err.Error() pass-through ~9; fmt.Sprintf + prefix ~4; hardcoded generic ~8; mixed ~6.
Reusable primitives:grafana-plugin-sdk-go (backend.ErrorSource, ErrorSourceFromHTTPStatus, httpclient ErrorSourceMiddleware) classifies source but not the message; sqlds has a shared CheckHealth + HealthChecker; grafana-aws-sdk has a shared AsyncAWSDatasource.CheckHealth.
6. Detailed design
6.1 Concepts / glossary
Code — stable, coarse error category (the versioned public vocabulary).
UNEXPECTED_RESPONSE is the fallback for "the server answered, but not with the format we expected" — an HTML error/login page where JSON was expected, or a JSON error body we couldn't interpret (see §6.4a). It is used only when classification can't refine to a more specific code.
Each code maps to a canonical headline + generic fallback remediation + default ErrorSource. Codes are additive and never repurposed.
6.3 Diagnosis dimensions (why remediation isn't only "auth type")
registered family classifiers (set Code + sub-signals) →
generic Go inspection (errors.Is/As on context.DeadlineExceeded, net.DNSError, net.OpError, x509/tls) →
HTTP-status helper →
UNKNOWN.
6.4a Interpreting upstream response bodies
A large class of real-world failures isn't a transport error at all — the server answered, but with a body that isn't the JSON the plugin expected. Two recurring cases: an HTML page (proxy/LB/WAF/SSO interception, or a URL pointing at a web root) and a JSON error envelope whose shape varies per provider. Left raw, both surface as opaque parse errors like invalid character '<' looking for beginning of value.
Guiding principle — classify, don't echo. The upstream body is used to classify (pick Code + ProviderCode) and is recorded only in the redacted verbose. The human Message is always built from catalog/rule copy keyed by Code/ProviderCode, so output stays stable regardless of upstream chaos. The body is treated as untrusted: parsing is size- and depth-bounded and must degrade to a fallback code rather than crash the health check.
HTML responses. HTML is a symptom, not a category — it almost always means something between Grafana and the API answered. A response-aware helper sniffs Content-Type: text/html or a body starting with <!doctype/<html/<?xml and refines by status + context:
Observed
Likely cause
Code
HTML + 502/503/504
gateway / LB / upstream down
UPSTREAM_ERROR (or HOST_UNREACHABLE)
HTML + 401, or 200 + HTML login/SSO page
auth proxy / SSO intercept (common with PDC/proxy)
AUTHENTICATION_FAILED
HTML + 403 (WAF / block page)
firewall / WAF
PERMISSION_DENIED
HTML + 200, wrong path / web root
URL points at a web server, not the API
INVALID_CONFIGURATION (highlight URL field)
HTML, status genuinely ambiguous
unknown interceptor
UNEXPECTED_RESPONSE
The 200-OK-HTML + networkPath = pdc\|proxy combination is special-cased toward AUTHENTICATION_FAILED — it's the silent SSO-redirect trap in enterprise setups, where a JSON parse error would be especially misleading.
Inconsistent JSON error envelopes. Every provider shapes errors differently, so parsing is delegated to provider classifiers (ADR-002/007): each family extracts its own provider code authoritatively (Prometheus errorType, Elastic error.type, AWS smithy Code, Azure AADSTS#, JSON:API errors[].status, GraphQL extensions.code, SQLSTATE, …). ProviderCode — a stable machine code, not the free-text message — is the join key for remediation.
For the long tail / not-yet-classified plugins, a best-effort generic extractor walks common envelopes (case-insensitive, defensively): top-level error (string or object), message, error_description, detail, reason, title; arrays errors[0].{detail,message,reason} and error.root_cause[0].reason; codes code, errorType, error.type, extensions.code, status. It yields a hint for verbose and an optional ProviderCode; if nothing parses, classification lands on UNEXPECTED_RESPONSE rather than dumping raw JSON.
Telemetry feedback loop (see §10). When the generic path fires (no provider classifier matched), emit a sampled, redacted record. That backlog drives which provider classifiers to author next — turning upstream inconsistency into a measurable worklist.
6.4b Timeouts, cancellation, and clock skew
A single CONNECTION_TIMEOUT is too coarse — the same word covers failures with different fixes. Classifiers therefore set a TimeoutKind sub-signal:
Often better framed as HOST_UNREACHABLE; remediation points at port/security-group/firewall.
read
server reachable but slow (heavy query, undersized instance)
Stays CONNECTION_TIMEOUT; remediation points at server load / increasing the timeout.
deadline
the CheckHealth gRPC deadline elapsed before the attempt finished
Plugin should respect the inbound ctx deadline and return promptly rather than hang the UI.
Because the backend usually can't tell a slow server from a silently-dropped connection, timeout remediation should name both possibilities.
Cancellation is not a timeout.context.Canceled means Grafana or the user aborted the check (e.g. navigated away); it is distinguished from context.DeadlineExceeded and returned as a benign, non-error result (HealthStatusUnknown) — never surfaced as a scary failure, never recorded as a span error. Only DeadlineExceeded maps to CONNECTION_TIMEOUT.
Error-source nuance. Timeouts default to downstream, but a too-aggressive plugin-side timeout is plugin-caused; the mapping is overridable per case.
Clock skew is a sneaky cross-cutting cause that today scatters across TLS_ERROR ("certificate not yet valid/expired") and AUTHENTICATION_FAILED (AWS RequestTimeTooSkewed / "Signature expired", JWT nbf/exp). It is recognized as a signal and yields a dedicated "check the host's clock / NTP" remediation rather than generic TLS/auth copy.
6.5 Remediation resolution
A list of Rule{When Match; Give Remediation} registered as plain Go literals. The resolver scores matching rules by specificity (more constrained dimensions = higher weight; ProviderCode/TLSKind weigh most), picks the highest, and falls back to the catalog's generic remediation. A RemediationProvider func is the escape hatch for logic-heavy cases. Rules live in the importing binary, so a family's rules are naturally scoped to that family's plugins.
6.6 Output contract
Message — "<DS name>: <headline> <remediation summary>"; never a raw DSN/host/token.
Status vs codes. The SDK CheckHealthResult.Status is a 3-value enum (Ok/Error/Unknown) — not a number — and is the only top-level status (non-goal N1). The machine-readable references are errorCode (taxonomy, drives localized copy + Troubleshoot link) and providerCode (the upstream's own machine code, when known). The schema is fixed — the additional diagnostic metadata (httpStatus, tlsKind/timeoutKind/bodyKind/contentType) is folded into the verbose string rather than added as new fields, so JSONDetails never grows new keys. The offending config field is surfaced through the existing remediation.fields.
Redaction:verbose is the raw error/body summary passed through a redactor (masks password/token/secret/api-key/authorization and credentials embedded in URLs), with the non-secret diagnostic signals appended; the existing message is preserved as the prefix.
Correlation ID: a short per-failure reference, surfaced in the UI and stamped on the matching log line (§10). It is the trace ID when a sampled trace is present, else a generated fallback (§10.4), so the reference a user copies resolves to the full distributed trace. It lets support retrieve detail without the UI ever dumping it — and lets verbose stay hidden by default (see §14).
6.7 Frontend consumption
The UI keys off errorCode for localized copy + a "Troubleshoot" doc link, renders remediation.steps, and highlights remediation.fields in the config form. Backwards compatible: older UIs still show Message.
Context: Core must be importable by every plugin, including HTTP-only ones, without dragging in cloud/SQL SDKs.
Decision: Put the taxonomy/classifier/remediation core in a new module github.com/grafana/dsconfig/health whose only dependency is grafana-plugin-sdk-go.
Consequences: Cheap for all importers; discoverable under the datasources-owned repo; adds one small module to maintain in the existing multi-module repo. To keep the graph SDK-only even for tracing, span annotation is done through an injected SpanRecorder rather than a direct OpenTelemetry import (see §10.4); the reference implementation in feat(health): normalized, actionable health-check errors (RFC #110) #111 has exactly one direct dependency — grafana-plugin-sdk-go.
ADR-002 — Register adapters at the call site / family libs, not in core
Status: Accepted.
Context: Provider-specific classification needs heavy SDKs (smithy, azcore, lib/pq).
Decision: Families (grafana-aws-sdk, sqlds, grafana-azure-sdk-go) and plugins self-register classifiers + rules via init(). The core never imports those SDKs.
Consequences: No dependency bloat; per-binary rule scoping; classification lives next to the connection code. Slight duplication of SDK deps the family libs already carry.
ADR-003 — Author remediation rules as Go literals, not YAML
Status: Accepted.
Context: Authors are Go developers; YAML adds a parser, embed, and templating.
Decision: Rules are []Rule Go literals registered via RegisterRules.
Consequences: Compile-time-checked, no new dependency, easy refactors; bulk copy edits require a rebuild (acceptable). A RemediationProvider func remains for dynamic cases.
Context: A flat code → message map can't express provider sub-codes, auth type, deployment, TLS sub-kind, etc.
Decision: Classifiers fill a structured Diagnosis; remediation is matched by Match over any subset of dimensions and ranked by specificity with graceful fallback.
Consequences: Scales by adding data; partial coverage still yields useful messages; ordering logic is centralized and testable.
Context: Need both human-friendly and machine-readable output without leaking secrets.
Decision:Message is built only from catalog/rule copy + DS name; JSONDetails has a fixed shape — errorCode/providerCode/errorSource/correlationId/remediation/verbose — and additional diagnostic metadata is folded into verbose rather than added as new fields.
Consequences: Stable, non-growing contract for frontend + telemetry; secrets never in Message; verbose redacted for support and carries the diagnostic signals as a suffix.
Context: Backend strings shouldn't be the localization surface.
Decision:errorCodes are additive and never repurposed; the frontend maps code → localized copy + doc link.
Consequences: Backend wording changes are non-breaking; i18n is centralized in the frontend.
ADR-007 — Upstream response-body interpretation is delegated and defensive
Status: Accepted.
Context: Upstream errors arrive as HTML pages (proxy/WAF/SSO interception) or as JSON envelopes whose shape differs per provider. The core can't assume a body format, and the raw body is unsafe to surface.
Decision: Provider classifiers own parsing of their own envelopes and extract a stable ProviderCode; a best-effort generic extractor covers the long tail; a response-aware helper maps HTML by status + context. The body only ever classifies — it is never echoed into Message, only into a redacted verbose — and parsing is size/depth-bounded, degrading to UNEXPECTED_RESPONSE. (See §6.4a, §11.)
Consequences: Stable output regardless of upstream format; new envelopes are handled by adding a classifier, not changing core; "classify, don't echo" keeps secrets out of surfaced copy. Generic-path hits are logged as the backlog for new classifiers.
ADR-008 — Classify on typed errors with deterministic precedence
Status: Accepted.
Context: Matching on SDK error strings breaks silently when smithy/lib/pq/azcore reword errors between versions, and init() registration order across packages is non-deterministic — so two matching classifiers could resolve differently per build.
Decision: Classifiers prefer errors.As/errors.Is against concrete SDK error types (string sniffing only as an explicit last resort), walk wrapped chains, and handle errors.Join. Resolution order is fixed by an explicit priority/specificity rule, never by registration order.
Consequences: Robust across SDK upgrades; deterministic, testable classification; a small priority contract every classifier must honour. The generic string-based classifier is the documented fallback, not the norm.
ADR-009 — Classify once, render to every surface (UI / logs / metrics / trace)
Status: Accepted.
Context: A health error serves three audiences with different safety bars; treating them as one string either leaks detail to the UI or starves logs/metrics.
Decision: Classify once into a Diagnosis, then render: a minimal safe Message+remediation+correlation ID to the UI; a fuller, secret-masked structured log line keyed by the same correlation ID; bounded-cardinality metrics (providerCode is logged, never a metric label); and a redacted record on the active trace span (SetStatus/RecordError/attributes). The log, metrics and span sinks are injected via options (WithLogger/WithMetrics/WithSpanRecorder) so the core stays dependency-light and the fan-out is testable. The correlation ID is the trace ID when sampled, else a generated fallback. Log severity is keyed on errorSource. (See §10, §10.4.)
Consequences: No surface reclassifies; the trace/correlation ID bridges UI, logs, traces and metric exemplars so verbose can stay hidden by default; redaction extends to spans (Tempo has its own access controls); cardinality discipline keeps metrics safe and cheap; classifier-coverage becomes a monitored regression signal.
9. Rollout
Phase 0 — authoritative audit: with org access, fetch each repo's src/plugin.json + CheckHealth; finalize connection-family counts and a per-repo conversion checklist.
Wave 1: land dsconfig/health; wire sqlds + grafana-aws-sdk shared CheckHealth → ~18 SQL/AWS plugins via dependency bumps.
Wave 3: long tail (bespoke HTTP, mqtt, sqleng postgres/mysql/mssql).
Each wave is independently shippable; un-migrated plugins keep working.
10. Observability — user, logs, telemetry
A single Diagnosis fans out to several surfaces with different audiences and safety bars (three consumer surfaces below, plus the trace span in §10.4). The library classifies once; no surface reclassifies.
Surface
Audience
Goal
Safety bar
UI (CheckHealthResult)
DS admin/editor
fix it now
strict — no secrets, no internal infra by default
Logs
operator / plugin dev / support
diagnose a specific failure
masked, but fuller; correlatable
Metrics
SRE / PM / DS team
trends, alerting, classifier backlog
aggregate only, bounded cardinality
10.1 What the user sees (UI)
Message, remediation, errorCode, errorSource, providerCode, and a correlation ID (§6.6); the diagnostic metadata (httpStatus/tlsKind/timeoutKind/bodyKind/contentType) rides inside the redacted verbose string, keeping the schema fixed. No raw error, stack trace, internal host/DSN, or upstream body; verbose is hidden by default (see §14) — the correlation ID is the bridge to detail.
10.2 What we log
Structured (SDK logger, key/value): errorCode, providerCode, errorSource, datasourceType, datasourceUID (prefer UID over name — names can be PII), sub-signals (timeoutKind/tlsKind/bodyKind), httpStatus, correlationId, classifierPath (tag/family/generic/unknown), check duration, and the redactedverbose; carry trace/span IDs when OTel is present.
Log once at the boundary where health.Result is built, not at every wrap layer. Secrets always masked, even in logs; infra detail (host) may be allowed in logs but never the UI (policy knob).
10.3 How we track / monitor (metrics)
Counters + a histogram, with strict label cardinality:
Bounded labels only.errorCode (closed taxonomy), datasourceType (~50), errorSource (2), small enum sub-signals. providerCode is NOT a metric label (unbounded — AADSTS/SQLSTATE/etc.); it lives in logs (and metric exemplars if a bridge is wanted).
Coverage is first-class. Track the rate of classifierPath="generic", UNKNOWN, and UNEXPECTED_RESPONSE — it is both the classifier backlog and the regression alarm (a sudden spike usually means an SDK reworded its errors and a string-match classifier broke — the ADR-008 risk).
Alert on signal, not baseline: spike in errorSource=plugin/UNKNOWN → likely a release regression (page); spike in one errorCode×datasourceType → upstream incident or bad release; don't alert on baseline downstream config errors — track those as an onboarding funnel (first-Save&test success rate) instead.
Privacy: metrics are safe only while labels stay bounded and free of free text; respect telemetry opt-out (OSS) and route to usage-insights / the Cloud pipeline accordingly (see §14).
10.4 Trace propagation & correlation
A distributed trace already flows through CheckHealth; the correlation ID rides on it rather than competing with it.
Browser (Save & test)
│ HTTP (root span often starts here / at Grafana)
▼
Grafana server ── creates/continues span ──┐
│ gRPC (W3C traceparent in metadata) │ one trace
▼ │
Plugin backend CheckHealth(ctx, req) ◄──────┘ ctx carries trace+span
│ outbound (httpclient / otelsql / otelaws)
▼
Downstream datasource (usually NOT in your trace)
Caller threading: the plugin passes the inbound ctx from CheckHealth(ctx, req) straight through its connection attempt into Result(ctx, …). The health.Contextdimensions (datasource type/auth/network) are assembled from req separately — distinct from the Go ctx.
Inbound context: the SDK's OTel gRPC interceptors propagate trace context, so ctx carries the trace/span; read it via tracing.TraceIDFromContext(ctx, true) / OTel SpanContextFromContext.
Outbound: the SDK httpclient tracing middleware injects traceparent and opens a client span; otelaws does the same and captures the AWS request ID; otelsql wraps queries. The upstream rarely joins the trace, so the plugin-side client span is the unit of truth — operators shouldn't hunt for a downstream span that never existed.
Span recording is injected.Result records the redacted classification onto the span (SetStatus(Error), RecordError, attributes health.code/error_source/provider_code/timeout_kind/classifier_path) via a caller-supplied SpanRecorder, so the core import graph stays SDK-only (ADR-001); an otel-backed recorder lives in the plugin/family lib. Redaction extends to spans — they ship to Tempo under different access controls than logs, so the same masking runs first.
Sets correlationId = trace ID when sampled, else a generated fallback; stamps traceID/spanID on the log line.
How each scenario reads in a trace:
Scenario
What the trace shows
The tell
HTML / proxy-WAF-SSO interception
outbound client span errors/odd status; no child span from the real datasource
the gap is diagnostic — the request never reached the API
Timeout
client span duration pinned at the deadline; health.timeout_kind attr
dial vs read visible from where time went
Cancellation
span ends cancelled
treat as benign — do notSetStatus(Error) (avoids polluting error dashboards)
SQL / AWS
otelsql/otelaws client span with db.system / AWS request ID
AWS request ID is the cross-ref to vendor support, like providerCode
Stitching the three pillars:traceID/spanID on logs → Loki↔Tempo derived-field links; metric exemplars carry traceID so a spike on ..._failures_total links straight to a failing trace; the UI correlation ID resolves the whole trace.
Caveats:
Sampling drops failures. Traces are head-sampled, so a traceID may have no stored trace. Health checks are low-volume / high-value — force sampling on CheckHealth errors (sampling priority / sample-on-error) so a failure trace is never lost.
Tracing may be off (OSS/unconfigured): degrade gracefully — generate the correlation ID, still log and emit metrics; handle zero/un-sampled span context.
Conformance helper: every declared (datasourceType × authType × code) resolves to a non-empty remediation with a docsUrl.
CI lint: fail any CheckHealth returning raw err.Error() instead of health.Result.
11. Security & privacy
Redaction runs on verbose; Message is built only from catalog/rule copy + datasource name, never the raw error. ErrorSource mapping keeps plugin- vs downstream-caused failures correct for SLOs.
Response bodies are never dumped raw into verbose. HTML error/login pages and JSON error envelopes frequently embed internal hostnames, stack traces, CSRF/session tokens, and form details. The redactor therefore:
HTML: records contentType, byte length, status, and at most the <title> or first ~200 chars of visible text — e.g. HTML response (text/html, 4096 bytes, 200): "Sign in - Okta" — run through the secret masker; never the full page.
JSON: records only the extracted message/ProviderCode hint (see §6.4a), masked — not the whole body.
Bodies are size- and depth-capped before parsing and treated as untrusted, so a malformed/huge/non-UTF8 body degrades to UNEXPECTED_RESPONSE instead of leaking or crashing.
12. Backwards compatibility & versioning
The errorCode taxonomy is additive; codes never change meaning. JSONDetails has a fixed shape — diagnostic metadata is folded into verbose rather than added as new top-level fields, so the contract never grows new keys. Adoption is per-plugin and independent.
13. Alternatives considered
Everything in one self-contained module (incl. all adapters): forces AWS+Azure+SQL+GCP deps onto every importer. Rejected (dependency bloat). [ADR-001/002]
Core in grafana-plugin-sdk-go: ubiquitous, but baking opinionated copy into the core SDK is undesirable and ties cadence to the SDK. Rejected; reuse SDK primitives only.
YAML-authored rules: flexible/i18n-friendly but adds parser/embed/templating. Rejected. [ADR-003]
Per-repo bespoke fixes: no shared contract; doesn't scale. Rejected.
14. Open questions
Exact module path within grafana/dsconfig (/health vs a package in the existing /dsconfig module). The reference implementation (feat(health): normalized, actionable health-check errors (RFC #110) #111) places it at /health as its own module; relocating is cheap if reviewers prefer otherwise.
Frontend ownership of the errorCode → localized-copy + doc-link mapping.
Standardize AuthType/Deployment enums centrally, or per family?
verbose visibility: hide by default and rely on the correlation ID (§6.6) + logs, or gate verbose behind a debug/admin flag in the UI? (Even redacted, it can reveal upstream hostnames.)
Telemetry destination & opt-out: where do the §10.3 metrics land (usage-insights / Cloud pipeline), and how is OSS telemetry opt-out honoured?
Model a non-binary "warning/degraded" outcome, or stay strictly OK-vs-error? Real cases — connected but a deprecated server version, partial permissions, or TLS verification disabled — are soft failures. Options: (a) add a degraded/warning result (richer UX, new frontend contract, more surface); (b) stay binary and surface soft issues as text in the OK message (simpler, matches backend.HealthStatus today). Decide before implementation.
15. Operational & edge-case considerations
Multiple problems → one code. A check can fail several ways at once; resolution applies a fixed precedence (config → TLS → auth → connection → permission) and reports the most actionable single Code. This precedence also governs tag selection when an error tree carries several tags (e.g. errors.Join) — Diagnose collects all tags across the unwrap tree and picks by rank, not traversal order.
Redaction is defense-in-depth, not the primary control. Keyword/regex masking has false negatives, so "classify, don't echo" (§6.4a) stays the main safeguard; verbose favours an allowlist of known-safe fields over denylisting bad ones, plus size caps. (Surface routing — UI vs logs vs metrics — is covered in §10.)
Rate limiting carries detail. When present, Retry-After is captured into the remediation ("retry after Ns").
Defensive defaults.Result(nil) → OK; missing Context → generic remediation; zero/unknown Code → UNKNOWN; classification never panics on malformed input.
Force-sample failed health checks. Traces are head-sampled, so a failure's trace can be dropped; set sampling priority / sample-on-error in CheckHealth so the high-value failure trace (§10.4) is retained.
Frontend must tolerate unknown future codes (requirement, not aside). A newer backend errorCode an older UI doesn't recognize must fall back to rendering Message — never a blank/typed-as-unknown state.
Public surface of the health module, matching the reference implementation in #111.
package health
import (
"context""net/http""time""github.com/grafana/grafana-plugin-sdk-go/backend"
)
// Code is the stable, coarse, public error vocabulary (additive, never repurposed).typeCodestringconst (
CodeInvalidConfigurationCode="INVALID_CONFIGURATION"CodeAuthenticationFailedCode="AUTHENTICATION_FAILED"CodePermissionDeniedCode="PERMISSION_DENIED"CodeHostUnreachableCode="HOST_UNREACHABLE"CodeConnectionTimeoutCode="CONNECTION_TIMEOUT"CodeTLSErrorCode="TLS_ERROR"CodeNotFoundCode="NOT_FOUND"CodeRateLimitedCode="RATE_LIMITED"CodeQuotaExceededCode="QUOTA_EXCEEDED"CodeUpstreamErrorCode="UPSTREAM_ERROR"CodeUnsupportedVersionCode="UNSUPPORTED_VERSION"CodeQueryValidationCode="QUERY_VALIDATION_FAILED"CodeUnexpectedResponseCode="UNEXPECTED_RESPONSE"CodeUnknownCode="UNKNOWN"
)
// Error tags an error with an explicit Code and optional sub-signals (highest classifier priority).typeErrorstruct {
CodeCodeProviderCodestringFieldstringErrerror
}
func (e*Error) Error() string { returne.Err.Error() }
func (e*Error) Unwrap() error { returne.Err }
funcWithCode(codeCode, errerror) error { return&Error{Code: code, Err: err} }
funcTag(errerror, codeCode, providerCode, fieldstring) error {
return&Error{Code: code, ProviderCode: providerCode, Field: field, Err: err}
}
// Diagnosis is the structured classification result; Context carries call-site dimensions.typeTLSKindstringconst (
TLSUnknownAuthorityTLSKind="unknown_authority"TLSHostnameMismatchTLSKind="hostname_mismatch"TLSExpiredTLSKind="expired"TLSClientCertTLSKind="client_cert"
)
typeContextstruct {
DatasourceTypestringDatasourceNamestringAuthTypestringDeploymentstring// cloud | enterprise | ossNetworkPathstring// direct | pdc | proxyDocsBaseURLstringVarsmap[string]string
}
// BodyKind captures the shape of an upstream response body (see §6.4a).typeBodyKindstringconst (
BodyJSONBodyKind="json"BodyHTMLBodyKind="html"BodyTextBodyKind="text"
)
// TimeoutKind distinguishes dial vs read vs the CheckHealth deadline (see §6.4b).typeTimeoutKindstringconst (
TimeoutDialTimeoutKind="dial"// connect timed out (often firewall/wrong port)TimeoutReadTimeoutKind="read"// reachable but slowTimeoutDeadlineTimeoutKind="deadline"// CheckHealth ctx deadline elapsed
)
// ClassifierPath records which pipeline stage produced a Diagnosis (RFC §10.2/§10.3).typeClassifierPathstringconst (
PathTagClassifierPath="tag"PathFamilyClassifierPath="family"PathGenericClassifierPath="generic"PathUnknownClassifierPath="unknown"
)
typeDiagnosisstruct {
CodeCodeProviderCodestringHTTPStatusintTLSKindTLSKindTimeoutKindTimeoutKind// when Code is CONNECTION_TIMEOUT / HOST_UNREACHABLEBodyKindBodyKind// sniffed response-body shape, when knownContentTypestring// upstream response Content-Type, when knownFieldstring// offending config field, when knownPathClassifierPathContextContext
}
// Classifier maps a raw error to a Diagnosis. Families register these via init().typeClassifierfunc(errerror, ctxContext) (Diagnosis, bool)
// RegisterClassifier registers c at the default priority (0).funcRegisterClassifier(cClassifier)
// RegisterClassifierWithPriority registers c; higher priority wins, ties break by// registration order — never by init() ordering across packages (ADR-008).funcRegisterClassifierWithPriority(priorityint, cClassifier)
// Diagnose runs the classification pipeline (RFC §6.4). It collects every *Error// tag across the unwrap tree (incl. errors.Join) and picks the most actionable by// precedence (RFC §15); otherwise it tries family classifiers then generic// net/TLS/timeout inspection. Never returns an empty Code, never panics.funcDiagnose(errerror, ctxContext) Diagnosis// ClassifyHTTPResponse inspects an HTTP response (status + Content-Type + body)// and refines HTML/non-JSON answers by status and context (§6.4a). HTTP families// call this before unmarshalling so headers are available to the classifier.funcClassifyHTTPResponse(resp*http.Response, body []byte, ctxContext) (Diagnosis, bool)
// ExtractJSONError is a best-effort, never-authoritative reader for arbitrary// JSON error envelopes (§6.4a). It tolerates error-as-string|object|array,// numeric-or-string codes, nesting, and missing fields; parsing is size- and// depth-bounded and never panics. Returns ok=false when nothing parses.funcExtractJSONError(body []byte) (msgstring, providerCodestring, okbool)
// Remediation is the structured, surfaced guidance.typeRemediationstruct {
Summarystring`json:"summary"`Steps []string`json:"steps,omitempty"`DocsURLstring`json:"docsUrl,omitempty"`Fields []string`json:"fields,omitempty"`
}
// Match is a declarative condition set; empty fields are wildcards. Rules are Go literals.typeMatchstruct {
CodeCodeProviderCodestringTLSKindTLSKindTimeoutKindTimeoutKindBodyKindBodyKindAuthTypestringDeploymentstring
}
typeRulestruct {
WhenMatchGiveRemediation
}
funcRegisterRules(rules...Rule)
// Sinks are injected so the core stays dependency-light (ADR-001/009).// backend.Logger satisfies Logger directly; SpanRecorder is backed by an// otel adapter in the plugin so the core never imports OpenTelemetry.typeLoggerinterface {
Error(msgstring, args...any)
Warn(msgstring, args...any)
Info(msgstring, args...any)
}
typeMetricsinterface {
Observe(dDiagnosis, durationSecondsfloat64)
}
typeSpanRecorderinterface {
Record(ctx context.Context, dDiagnosis, redactedVerbosestring)
}
typeOptionfunc(*options)
funcWithContext(cContext) Option// classification/remediation dimensionsfuncWithLogger(lLogger) Option// structured log sink (§10.2)funcWithMetrics(mMetrics) Option// metrics sink (§10.3)funcWithSpanRecorder(sSpanRecorder) Option// trace span sink (§10.4)funcWithVerbose(includebool) Option// include redacted verbose in JSONDetails (default off)funcWithDuration(d time.Duration) Option// Result classifies err once and renders it four ways (ADR-009): the safe UI// payload is returned; the log/metrics/span sinks fire only when injected. The// inbound ctx supplies the trace for the correlation ID and span recording.// - err == nil → OK// - benign cancellation → HealthStatusUnknown (RFC §6.4b)// - otherwise → HealthStatusError + JSONDetailsfuncResult(ctx context.Context, errerror, opts...Option) *backend.CheckHealthResult// ResultForResponse is the response-aware entry point for HTTP families (§6.4a):// it inspects status + Content-Type + body so HTML/odd answers classify with// their sub-signals preserved. A 2xx with a normal body and no rawErr → OK.funcResultForResponse(ctx context.Context, resp*http.Response, body []byte, rawErrerror, opts...Option) *backend.CheckHealthResult// OK is the success constructor.funcOK(messagestring) *backend.CheckHealthResult
github.com/grafana/dsconfig(newhealthmodule) + family libsgrafana-plugin-sdk-go(ErrorSource),sqlds,grafana-aws-sdk,grafana-azure-sdk-go,grafana-google-sdk-go1. Summary
Datasource health checks (the
CheckHealthbehind Save & test) return inconsistent, often raw or opaque error messages across the ~50 datasource plugins.This RFC proposes a small shared library —
dsconfig/health— that normalizes every health-check failure into a consistent, safe, machine-classified, and actionable result:errorCode,The core module depends only on
grafana-plugin-sdk-go, so HTTP-only plugins incur no SDK bloat. Most plugins adopt it via a dependency bump, because the logic plugs into the sharedCheckHealthpaths already provided bysqldsandgrafana-aws-sdk.2. Motivation & background
CheckHealthis the first real interaction during onboarding; a confusing failure here is a major drop-off point and a top support driver. Problems today:err.Error()raw, somefmt.Sprintfa prefix, some a hardcoded generic string. No common structure across repos.3. Goals / Non-goals
3.1 Goals
errorCodetaxonomy for docs, i18n, and telemetry.ErrorSource(plugin vs downstream) attribution.3.2 Non-goals
CheckHealthcontract or the Save & test UX flow.errorCode→ localized copy).4. Requirements
4.1 Functional
errorto exactly oneCodefrom a closed taxonomy.Messageand a structuredJSONDetails.Diagnosis(not just auth type).errorSource(plugin/downstream) per category.OK).4.2 Non-functional
grafana-plugin-sdk-go(no SQL/AWS/Azure/GCP SDKs).JSONDetailsis a stable, additively-versioned contract.5. Current-state audit
Connection families (where normalization plugs in):
sqldsdatabase/sqlbehind sqldsSQLDatasource.CheckHealth(sqlds)sqlengsqleng/handler_checkhealth.goAsyncAWSDatasource.CheckHealth(grafana-aws-sdk)Headline: ~30 plugins use an SDK/driver; ~20 use a direct HTTP connection.
Current error-message patterns (~25 sampled): raw
err.Error()pass-through ~9;fmt.Sprintf+ prefix ~4; hardcoded generic ~8; mixed ~6.Reusable primitives:
grafana-plugin-sdk-go(backend.ErrorSource,ErrorSourceFromHTTPStatus, httpclientErrorSourceMiddleware) classifies source but not the message;sqldshas a sharedCheckHealth+HealthChecker;grafana-aws-sdkhas a sharedAsyncAWSDatasource.CheckHealth.6. Detailed design
6.1 Concepts / glossary
Code+ sub-signals (ProviderCode,HTTPStatus,TLSKind, offendingField) +Context.errorinto aDiagnosis. Families that own an SDK register them; a generic classifier covers net/TLS/timeout.summary,steps[],docsUrl,fields[].(conditions → Remediation); conditions are any subset of dimensions (empty = wildcard); resolved by specificity.6.2 Error-code taxonomy
INVALID_CONFIGURATION(plugin),AUTHENTICATION_FAILED,PERMISSION_DENIED,HOST_UNREACHABLE,CONNECTION_TIMEOUT,TLS_ERROR,NOT_FOUND,RATE_LIMITED,QUOTA_EXCEEDED,UPSTREAM_ERROR,UNSUPPORTED_VERSION,QUERY_VALIDATION_FAILED,UNEXPECTED_RESPONSE(downstream),UNKNOWN(plugin).UNEXPECTED_RESPONSEis the fallback for "the server answered, but not with the format we expected" — an HTML error/login page where JSON was expected, or a JSON error body we couldn't interpret (see §6.4a). It is used only when classification can't refine to a more specific code.Each code maps to a canonical headline + generic fallback remediation + default
ErrorSource. Codes are additive and never repurposed.6.3 Diagnosis dimensions (why remediation isn't only "auth type")
error category · provider sub-code (AADSTS / AWS error code / SQLSTATE / HTTP status) · datasource family · auth method · deployment (cloud/enterprise/oss) · network path (direct/PDC/proxy) · TLS sub-kind (unknown-CA/hostname/expired/client-cert) · offending config field · permission scope · server version/capability · region/cloud variant · transient-vs-persistent.
6.4 Classification pipeline
Priority, first match wins:
Errortag →Code+ sub-signals) →errors.Is/Asoncontext.DeadlineExceeded,net.DNSError,net.OpError,x509/tls) →UNKNOWN.6.4a Interpreting upstream response bodies
A large class of real-world failures isn't a transport error at all — the server answered, but with a body that isn't the JSON the plugin expected. Two recurring cases: an HTML page (proxy/LB/WAF/SSO interception, or a URL pointing at a web root) and a JSON error envelope whose shape varies per provider. Left raw, both surface as opaque parse errors like
invalid character '<' looking for beginning of value.Guiding principle — classify, don't echo. The upstream body is used to classify (pick
Code+ProviderCode) and is recorded only in the redactedverbose. The humanMessageis always built from catalog/rule copy keyed byCode/ProviderCode, so output stays stable regardless of upstream chaos. The body is treated as untrusted: parsing is size- and depth-bounded and must degrade to a fallback code rather than crash the health check.HTML responses. HTML is a symptom, not a category — it almost always means something between Grafana and the API answered. A response-aware helper sniffs
Content-Type: text/htmlor a body starting with<!doctype/<html/<?xmland refines by status + context:UPSTREAM_ERROR(orHOST_UNREACHABLE)AUTHENTICATION_FAILEDPERMISSION_DENIEDINVALID_CONFIGURATION(highlight URL field)UNEXPECTED_RESPONSEThe 200-OK-HTML +
networkPath = pdc\|proxycombination is special-cased towardAUTHENTICATION_FAILED— it's the silent SSO-redirect trap in enterprise setups, where a JSON parse error would be especially misleading.Inconsistent JSON error envelopes. Every provider shapes errors differently, so parsing is delegated to provider classifiers (ADR-002/007): each family extracts its own provider code authoritatively (Prometheus
errorType, Elasticerror.type, AWS smithyCode, AzureAADSTS#, JSON:APIerrors[].status, GraphQLextensions.code, SQLSTATE, …).ProviderCode— a stable machine code, not the free-text message — is the join key for remediation.For the long tail / not-yet-classified plugins, a best-effort generic extractor walks common envelopes (case-insensitive, defensively): top-level
error(string or object),message,error_description,detail,reason,title; arrayserrors[0].{detail,message,reason}anderror.root_cause[0].reason; codescode,errorType,error.type,extensions.code,status. It yields a hint forverboseand an optionalProviderCode; if nothing parses, classification lands onUNEXPECTED_RESPONSErather than dumping raw JSON.Telemetry feedback loop (see §10). When the generic path fires (no provider classifier matched), emit a sampled, redacted record. That backlog drives which provider classifiers to author next — turning upstream inconsistency into a measurable worklist.
6.4b Timeouts, cancellation, and clock skew
A single
CONNECTION_TIMEOUTis too coarse — the same word covers failures with different fixes. Classifiers therefore set aTimeoutKindsub-signal:dialHOST_UNREACHABLE; remediation points at port/security-group/firewall.readCONNECTION_TIMEOUT; remediation points at server load / increasing the timeout.deadlineCheckHealthgRPC deadline elapsed before the attempt finishedctxdeadline and return promptly rather than hang the UI.Because the backend usually can't tell a slow server from a silently-dropped connection, timeout remediation should name both possibilities.
Cancellation is not a timeout.
context.Canceledmeans Grafana or the user aborted the check (e.g. navigated away); it is distinguished fromcontext.DeadlineExceededand returned as a benign, non-error result (HealthStatusUnknown) — never surfaced as a scary failure, never recorded as a span error. OnlyDeadlineExceededmaps toCONNECTION_TIMEOUT.Error-source nuance. Timeouts default to
downstream, but a too-aggressive plugin-side timeout isplugin-caused; the mapping is overridable per case.Clock skew is a sneaky cross-cutting cause that today scatters across
TLS_ERROR("certificate not yet valid/expired") andAUTHENTICATION_FAILED(AWSRequestTimeTooSkewed/ "Signature expired", JWTnbf/exp). It is recognized as a signal and yields a dedicated "check the host's clock / NTP" remediation rather than generic TLS/auth copy.6.5 Remediation resolution
A list of
Rule{When Match; Give Remediation}registered as plain Go literals. The resolver scores matching rules by specificity (more constrained dimensions = higher weight;ProviderCode/TLSKindweigh most), picks the highest, and falls back to the catalog's generic remediation. ARemediationProviderfunc is the escape hatch for logic-heavy cases. Rules live in the importing binary, so a family's rules are naturally scoped to that family's plugins.6.6 Output contract
Message—"<DS name>: <headline> <remediation summary>"; never a raw DSN/host/token.JSONDetails(fixed shape):{ "errorCode": "...", "providerCode": "...", "errorSource": "plugin|downstream", "correlationId": "a1b2c3", "remediation": { "summary": "...", "steps": ["..."], "docsUrl": "...", "fields": ["..."] }, "verbose": "<redacted raw error> [httpStatus=502 bodyKind=html contentType=text/html]" }CheckHealthResult.Statusis a 3-value enum (Ok/Error/Unknown) — not a number — and is the only top-level status (non-goal N1). The machine-readable references areerrorCode(taxonomy, drives localized copy + Troubleshoot link) andproviderCode(the upstream's own machine code, when known). The schema is fixed — the additional diagnostic metadata (httpStatus,tlsKind/timeoutKind/bodyKind/contentType) is folded into theverbosestring rather than added as new fields, soJSONDetailsnever grows new keys. The offending config field is surfaced through the existingremediation.fields.verboseis the raw error/body summary passed through a redactor (masks password/token/secret/api-key/authorization and credentials embedded in URLs), with the non-secret diagnostic signals appended; the existing message is preserved as the prefix.verbosestay hidden by default (see §14).6.7 Frontend consumption
The UI keys off
errorCodefor localized copy + a "Troubleshoot" doc link, rendersremediation.steps, and highlightsremediation.fieldsin the config form. Backwards compatible: older UIs still showMessage.7. Architecture & placement
8. Architecture Decision Records
ADR-001 — Dedicated light
dsconfig/healthmodule (SDK-only)github.com/grafana/dsconfig/healthwhose only dependency isgrafana-plugin-sdk-go.SpanRecorderrather than a direct OpenTelemetry import (see §10.4); the reference implementation in feat(health): normalized, actionable health-check errors (RFC #110) #111 has exactly one direct dependency —grafana-plugin-sdk-go.ADR-002 — Register adapters at the call site / family libs, not in core
grafana-aws-sdk,sqlds,grafana-azure-sdk-go) and plugins self-register classifiers + rules viainit(). The core never imports those SDKs.ADR-003 — Author remediation rules as Go literals, not YAML
embed, and templating.[]RuleGo literals registered viaRegisterRules.RemediationProviderfunc remains for dynamic cases.ADR-004 — Rich, multi-dimensional Diagnosis + specificity-ranked rules
code → messagemap can't express provider sub-codes, auth type, deployment, TLS sub-kind, etc.Diagnosis; remediation is matched byMatchover any subset of dimensions and ranked by specificity with graceful fallback.ADR-005 — Split output: safe
Message+ structuredJSONDetails, redact verboseMessageis built only from catalog/rule copy + DS name;JSONDetailshas a fixed shape —errorCode/providerCode/errorSource/correlationId/remediation/verbose— and additional diagnostic metadata is folded intoverboserather than added as new fields.Message;verboseredacted for support and carries the diagnostic signals as a suffix.ADR-006 — Additive, versioned taxonomy; frontend owns localization
errorCodes are additive and never repurposed; the frontend maps code → localized copy + doc link.ADR-007 — Upstream response-body interpretation is delegated and defensive
ProviderCode; a best-effort generic extractor covers the long tail; a response-aware helper maps HTML by status + context. The body only ever classifies — it is never echoed intoMessage, only into a redactedverbose— and parsing is size/depth-bounded, degrading toUNEXPECTED_RESPONSE. (See §6.4a, §11.)ADR-008 — Classify on typed errors with deterministic precedence
init()registration order across packages is non-deterministic — so two matching classifiers could resolve differently per build.errors.As/errors.Isagainst concrete SDK error types (string sniffing only as an explicit last resort), walk wrapped chains, and handleerrors.Join. Resolution order is fixed by an explicit priority/specificity rule, never by registration order.ADR-009 — Classify once, render to every surface (UI / logs / metrics / trace)
Diagnosis, then render: a minimal safeMessage+remediation+correlation ID to the UI; a fuller, secret-masked structured log line keyed by the same correlation ID; bounded-cardinality metrics (providerCodeis logged, never a metric label); and a redacted record on the active trace span (SetStatus/RecordError/attributes). The log, metrics and span sinks are injected via options (WithLogger/WithMetrics/WithSpanRecorder) so the core stays dependency-light and the fan-out is testable. The correlation ID is the trace ID when sampled, else a generated fallback. Log severity is keyed onerrorSource. (See §10, §10.4.)verbosecan stay hidden by default; redaction extends to spans (Tempo has its own access controls); cardinality discipline keeps metrics safe and cheap; classifier-coverage becomes a monitored regression signal.9. Rollout
src/plugin.json+CheckHealth; finalize connection-family counts and a per-repo conversion checklist.dsconfig/health; wiresqlds+grafana-aws-sdksharedCheckHealth→ ~18 SQL/AWS plugins via dependency bumps.Each wave is independently shippable; un-migrated plugins keep working.
10. Observability — user, logs, telemetry
A single
Diagnosisfans out to several surfaces with different audiences and safety bars (three consumer surfaces below, plus the trace span in §10.4). The library classifies once; no surface reclassifies.CheckHealthResult)10.1 What the user sees (UI)
Message,remediation,errorCode,errorSource,providerCode, and a correlation ID (§6.6); the diagnostic metadata (httpStatus/tlsKind/timeoutKind/bodyKind/contentType) rides inside the redactedverbosestring, keeping the schema fixed. No raw error, stack trace, internal host/DSN, or upstream body;verboseis hidden by default (see §14) — the correlation ID is the bridge to detail.10.2 What we log
Structured (SDK logger, key/value):
errorCode,providerCode,errorSource,datasourceType,datasourceUID(prefer UID over name — names can be PII), sub-signals (timeoutKind/tlsKind/bodyKind),httpStatus,correlationId,classifierPath(tag/family/generic/unknown), checkduration, and the redactedverbose; carry trace/span IDs when OTel is present.errorSourceto avoid alert fatigue:downstream(user misconfig) →warn/info;plugin/UNKNOWN/ classification-failed →error(our bugs).health.Resultis built, not at every wrap layer. Secrets always masked, even in logs; infra detail (host) may be allowed in logs but never the UI (policy knob).10.3 How we track / monitor (metrics)
Counters + a histogram, with strict label cardinality:
errorCode(closed taxonomy),datasourceType(~50),errorSource(2), small enum sub-signals.providerCodeis NOT a metric label (unbounded — AADSTS/SQLSTATE/etc.); it lives in logs (and metric exemplars if a bridge is wanted).classifierPath="generic",UNKNOWN, andUNEXPECTED_RESPONSE— it is both the classifier backlog and the regression alarm (a sudden spike usually means an SDK reworded its errors and a string-match classifier broke — the ADR-008 risk).errorSource=plugin/UNKNOWN→ likely a release regression (page); spike in oneerrorCode×datasourceType→ upstream incident or bad release; don't alert on baselinedownstreamconfig errors — track those as an onboarding funnel (first-Save&test success rate) instead.10.4 Trace propagation & correlation
A distributed trace already flows through
CheckHealth; the correlation ID rides on it rather than competing with it.ctxfromCheckHealth(ctx, req)straight through its connection attempt intoResult(ctx, …). Thehealth.Contextdimensions (datasource type/auth/network) are assembled fromreqseparately — distinct from the Goctx.ctxcarries the trace/span; read it viatracing.TraceIDFromContext(ctx, true)/ OTelSpanContextFromContext.traceparentand opens a client span;otelawsdoes the same and captures the AWS request ID;otelsqlwraps queries. The upstream rarely joins the trace, so the plugin-side client span is the unit of truth — operators shouldn't hunt for a downstream span that never existed.Resultrecords the redacted classification onto the span (SetStatus(Error),RecordError, attributeshealth.code/error_source/provider_code/timeout_kind/classifier_path) via a caller-suppliedSpanRecorder, so the core import graph stays SDK-only (ADR-001); an otel-backed recorder lives in the plugin/family lib. Redaction extends to spans — they ship to Tempo under different access controls than logs, so the same masking runs first.correlationId= trace ID when sampled, else a generated fallback; stampstraceID/spanIDon the log line.How each scenario reads in a trace:
health.timeout_kindattrdialvsreadvisible from where time wentSetStatus(Error)(avoids polluting error dashboards)otelsql/otelawsclient span withdb.system/ AWS request IDproviderCodeStitching the three pillars:
traceID/spanIDon logs → Loki↔Tempo derived-field links; metric exemplars carrytraceIDso a spike on..._failures_totallinks straight to a failing trace; the UI correlation ID resolves the whole trace.Caveats:
traceIDmay have no stored trace. Health checks are low-volume / high-value — force sampling onCheckHealtherrors (sampling priority / sample-on-error) so a failure trace is never lost.10.5 Testing & conformance
Diagnose/Resultcases → expectedCode,ErrorSource, redacted output, resolved remediation.(datasourceType × authType × code)resolves to a non-empty remediation with adocsUrl.CheckHealthreturning rawerr.Error()instead ofhealth.Result.11. Security & privacy
Redaction runs on
verbose;Messageis built only from catalog/rule copy + datasource name, never the raw error.ErrorSourcemapping keeps plugin- vs downstream-caused failures correct for SLOs.Response bodies are never dumped raw into
verbose. HTML error/login pages and JSON error envelopes frequently embed internal hostnames, stack traces, CSRF/session tokens, and form details. The redactor therefore:contentType, byte length, status, and at most the<title>or first ~200 chars of visible text — e.g.HTML response (text/html, 4096 bytes, 200): "Sign in - Okta"— run through the secret masker; never the full page.ProviderCodehint (see §6.4a), masked — not the whole body.UNEXPECTED_RESPONSEinstead of leaking or crashing.12. Backwards compatibility & versioning
The
errorCodetaxonomy is additive; codes never change meaning.JSONDetailshas a fixed shape — diagnostic metadata is folded intoverboserather than added as new top-level fields, so the contract never grows new keys. Adoption is per-plugin and independent.13. Alternatives considered
grafana-plugin-sdk-go: ubiquitous, but baking opinionated copy into the core SDK is undesirable and ties cadence to the SDK. Rejected; reuse SDK primitives only.14. Open questions
grafana/dsconfig(/healthvs a package in the existing/dsconfigmodule). The reference implementation (feat(health): normalized, actionable health-check errors (RFC #110) #111) places it at/healthas its own module; relocating is cheap if reviewers prefer otherwise.errorCode→ localized-copy + doc-link mapping.AuthType/Deploymentenums centrally, or per family?verbosevisibility: hide by default and rely on the correlation ID (§6.6) + logs, or gateverbosebehind a debug/admin flag in the UI? (Even redacted, it can reveal upstream hostnames.)backend.HealthStatustoday). Decide before implementation.15. Operational & edge-case considerations
Code. This precedence also governs tag selection when an error tree carries several tags (e.g.errors.Join) —Diagnosecollects all tags across the unwrap tree and picks by rank, not traversal order.verbosefavours an allowlist of known-safe fields over denylisting bad ones, plus size caps. (Surface routing — UI vs logs vs metrics — is covered in §10.)Retry-Afteris captured into the remediation ("retry after Ns").Result(nil)→OK; missingContext→ generic remediation; zero/unknownCode→UNKNOWN; classification never panics on malformed input.CheckHealthso the high-value failure trace (§10.4) is retained.errorCodean older UI doesn't recognize must fall back to renderingMessage— never a blank/typed-as-unknown state.Appendix A — datasource repo inventory (52)
Expand the full list
astradb-datasource, athena-datasource, azure-cosmosdb-datasource, azure-data-explorer-datasource, azure-prometheus-datasource, clickhouse-datasource, dynamodb-datasource, falconlogscale-datasource, github-datasource, google-bigquery-datasource, google-sheets-datasource, grafana-amazonprometheus-datasource, grafana-aurora-datasource, grafana-azure-monitor-datasource, grafana-cloudmonitoring-datasource, grafana-cloudwatch-datasource, grafana-csv-datasource, grafana-cube-datasource, grafana-elasticsearch-datasource, grafana-graphite-datasource, grafana-infinity-datasource, grafana-influxdb-datasource, grafana-jaeger-datasource, grafana-loki-datasource, grafana-malefico-datasource, grafana-marketplacetest-datasource, grafana-mssql-datasource, grafana-mysql-datasource, grafana-opentsdb-datasource, grafana-parca-datasource, grafana-postgresql-datasource, grafana-prometheus-datasource, grafana-pyroscope-datasource, grafana-tempo-datasource, grafana-test-datasource, grafana-zipkin-datasource, grafanacon-custom-data-source-plugin-example, hackathon-13-rapid7-ics, iot-sitewise-datasource, jenkins-datasource, mock-datasource, mqtt-datasource, opensearch-datasource, pagerduty-datasource, redshift-datasource, sentry-datasource, strava-datasource, tailscale-grafana-plugin, test-datasource, timestream-datasource, x-ray-datasource, yugabyte-datasource.
(+ monorepos
grafana/plugins,grafana/plugins-privateto sweep; app-bundledgrafana-k6-app,asserts-app-plugin,hackathon-16-tamagrotchi-grafana-appexcluded.)Appendix B — API sketch