Documentation navigation
Querying Loki (LogQL)
How to query Corti logs in Loki via the Synapse MCP or Grafana. This page covers the label conventions, the patterns that actually work in this stack, and the production log-level gotcha that catches people out.
The Mimir/PromQL companion is Querying Mimir (PromQL). For an agent-ready skill block bundling both, see Synapse MCP agent skill.
The #1 gotcha: Loki and Mimir use different label names
The same concept has a different label name in Loki vs Mimir. They are not interchangeable — using the wrong one returns empty results.
| concept | Loki stream label | Mimir/PromQL label |
|---|---|---|
| cluster | k8s_cluster_name |
cluster |
| namespace | k8s_namespace_name |
namespace |
| container | k8s_container_name |
container |
| deployment | k8s_deployment_name |
deployment |
| statefulset | k8s_statefulset_name |
statefulset |
| daemonset | k8s_daemonset_name |
daemonset |
| service | service_name |
job (usually) |
Rule of thumb: Loki labels are k8s_-prefixed; Mimir labels are not.
Loki indexed labels
Loki index labels — the ones inside {...} stream selectors.
environment
development, production, staging
NOTE: Loki only indexes 3 environment values. Mimir has 6 (adds
dev,prd,sandbox). On-prem and sandbox clusters may have metrics in Mimir but no logs in Loki — if a Loki query onenvironment="sandbox"returns nothing, that's why. For on-prem/sandbox logs, filter byk8s_cluster_namedirectly.
k8s_cluster_name
Cluster generations (see the platform wiki for full context):
- gen1 (on-prem/customer):
bellingham,bostonems,commonspirit,dssundhed,gmr,hamiltonctyoh,montgomerycty,pscdtucson,regionsjaelland,scottishas,sfdproduction,stlouiscounty - gen2 (Azure AKS):
prod-weu,prod-us,dev-weu,staging-eu - gen3 (Scaleway sovereign):
dev-par01,dev-par02,prd-par01,prd-par02,prd-ric02,stg-par01,stg-par02 - sandbox/misc:
cortistaging,engagestaging,sbxtestgmr,try
service_name (OTel service label — primary service selector)
Regex-match the deployment family, e.g. "ml-service-medical-coding-.*",
".*audio-bridge.*|.*transcription.*". Common values: agent-api,
agent-memory, agent-memory-mcp, assistant, authorization-service,
code-api, copilot-api-v2, copilot-audio-bridge, envoy-gateway,
keycloak, ml-service-routing, ml-service-transcription-online,
ml-service-formatting, ml-service-voice-command, ml-service-medical-coding-*,
ml-service-llm-reasoning, ml-service-facts, interaction-service,
call-service, review-service, task-service, transcript-service,
voice-vault, blob-watcher, platform-api, platform-service, orca,
corti-s1-20260707-epp, synapse-mcp, traefik, kafka-kafka-0, ...
Other Loki index labels
k8s_namespace_name— the K8s namespace (e.g.monitoring-system,postgres,shared,kafka,base, plus per-service/per-tenant namespaces)k8s_container_name— container within pod (e.g.envoy,postgres,manager,redis)k8s_deployment_name,k8s_statefulset_name,k8s_daemonset_name,k8s_cronjob_name,k8s_job_name— workload-scoped selectorsservice_namespace— OTel service namespace__stream_shard__— INTERNAL Loki sharding label, never use in queries
Older dashboards also use unprefixed
namespace/applabels (e.g. code-api, speech-quality-assessment). Prefer thek8s_*/service_nameconvention for new queries.
Log levels in production — many services run at WARN+ only
In environment="production", a lot of services are configured to log at
WARN level and above — INFO and DEBUG lines are never ingested into Loki.
So an INFO-level query against a prod service often returns empty even when
the service is healthy and logging — the logs simply aren't there.
This is service-dependent, not universal (verified 2026-08-21):
| service | INFO present in prod? | notes |
|---|---|---|
copilot-api-v2 |
yes | logs at INFO (request logs, facts pipeline) |
platform-api |
yes | logs at INFO (HTTP access logs via logger_middleware) |
authorization-service |
no | WARN+ only (e.g. level="WARN" "Invalid JWT") |
ml-service-llm-reasoning |
no | no logs ingested under this service_name at all |
envoy-gateway |
no | 154k lines/hr but NOT JSON with a level field — its own access-log format |
Implications:
- Don't assume empty = broken. A production INFO query returning nothing usually means the service logs at WARN+, not that it's down. Verify with a stats query — if the stream has entries but your level filter matches nothing, the service is logging at a higher level.
- Start at WARN/ERROR for prod error hunting —
| json | level=~\(?i).(warn|error|fatal).`` is the reliable production filter across services. - Not all services emit JSON with a
levelfield. Envoy and some infra components use their own format. If| json | level=~matches nothing across ALL levels but the stream has data, fall back to a raw-line filter:|= "error"or|~ "(?i)error|warn". - Some
service_namevalues have zero logs in Loki. If stats return 0 entries/streams, the service isn't ingesting to Loki — don't keep retrying with different filters; use Mimir metrics instead. - Non-prod (
development,staging) typically DO log at INFO/DEBUG — use those environments if you need verbose logs to reproduce an issue.
Query patterns (battle-tested from synapse-dashboards)
Error/warning log hunting — the canonical pattern
Parse JSON, filter on extracted level with case-insensitive regex:
{k8s_cluster_name=~"$cluster", k8s_namespace_name=~"$namespace", service_name=~"ml-service-medical-coding-.*"} | json | level=~`(?i).*error.*`
For warnings: | level=~\(?i).warn.``
keep operator — THE cardinality-reduction pattern (use this always)
keep drops all extracted labels except the ones named, BEFORE
filtering/aggregating. Always use it after | json to avoid exploding label
cardinality:
{k8s_cluster_name=~"$cluster", k8s_namespace_name=~"$namespace", service_name=~"ml-service-medical-coding-.*"} | json | keep level | level=~`(?i).*error.*`
Multi-field keep (then numeric filter on an extracted field):
... | json | keep route_name, response_code | response_code<200 or response_code>=500
Keep for a distinct count:
sum(count by (interaction_id) (count_over_time({service_name="copilot-audio-bridge", environment=~"$environment", k8s_cluster_name=~"$cluster", k8s_namespace_name=~"$namespace"} | json | keep interaction_id | interaction_id != "" [$__range])))
Count errors over time (log-derived metric)
sum(count_over_time({k8s_cluster_name=~"$cluster", k8s_namespace_name=~"$namespace", service_name=~"ml-service-medical-coding-.*"} | json | keep level | level=~`(?i).*error.*` [$__interval]))
Cheapest count — substring filter, no JSON parse
sum(count_over_time({namespace=~"$namespace", app=~".*code-api.*"} |= `error` [$__interval]))
Latency percentiles FROM log fields (unwrap + quantile_over_time)
Extract a numeric field and compute p90 over time (divide by 1e9 for ns→s):
(quantile_over_time(0.9, {k8s_cluster_name=~"$cluster", service_name="traefik"} |= `/documents/` | json | keep RequestMethod, RequestPath, Duration | RequestMethod=`POST` | RequestPath=~`/v2/.+/.+/documents/` | unwrap Duration [$__interval]) by ()) / 1e9
Extract a field with regexp + line_format (non-JSON logs)
sort_desc(topk(15, sum by (clientId, k8s_cluster_name) (count_over_time({service_name="keycloak", k8s_cluster_name=~"$cluster"} | json | environment=~"$environment" | level="WARN" | line_format `{{.message}}` | regexp `clientId="(?P<clientId>[^"]+)"` | keep clientId, k8s_cluster_name [$__range]))))
Normalize a label before group-by (label_format + regexReplaceAll)
... | json | keep route_name, response_code | response_code<200 or response_code>=500 | label_format route_name=`{{ regexReplaceAll "^httproute/shared/ai-gateway-route-(.+?)/rule/.*$" .route_name "${1}" }}`
Raw log volume / rate
sum(rate({k8s_cluster_name=~"$cluster"}[5m]))
sum by (k8s_cluster_name) (rate({k8s_cluster_name=~"$cluster"}[$__interval]))
Week-over-week comparison (offset 7d)
sum(rate({k8s_cluster_name=~"$cluster"}[$__interval]))
sum(rate({k8s_cluster_name=~"$cluster"}[$__interval] offset 7d))
Empty-result fallback
count(sum by (k8s_cluster_name) (count_over_time({...} |~ "(?i)(barman.*error|wal not found)" [1h])) > 0) or on() vector(0)
Loki optimization rules
- Narrow with label matchers FIRST, then line filters —
{k8s_cluster_name=..., k8s_namespace_name=..., service_name=...} |= "error"is cheaper than{k8s_cluster_name=...} |= "error". keepafter| json— always. Parsed JSON labels explode cardinality;keeplimits to what you aggregate on.|= "substr"is cheaper than|~ "regex"is cheaper than| json— use the cheapest filter that works. Substring|=for a known token; regex|~for patterns;| jsononly when you need extracted fields.count_over_time/ratefor charts; raw query for browsing — if you only need a count or rate, don't pull raw lines.- Stats before logs — run a cheap index-level stats query first; if it reports 0 entries, the stream is empty — don't run an expensive log query.
- Group by
k8s_cluster_nameto control cardinality across many clusters.
Common pitfalls
- Wrong label namespace —
k8s_cluster_namein Mimir orclusterin Loki → empty results. See the gotcha table above. environment="sandbox"in Loki — returns empty;sandboxis Mimir-only. Usek8s_cluster_nameto find sandbox/on-prem logs.- Production INFO/DEBUG queries return empty by design — many prod
services log at WARN+ only (e.g.
authorization-service). Empty INFO results ≠ broken. Start prod error-hunting atlevel=~\(?i).(warn|error).``. See the "Log levels in production" section. - Scanning an empty Loki stream — always run a stats query first; 0 entries means don't bother with the log query.
- Not all services log JSON with a
levelfield — envoy, some infra components use their own format; fall back to raw-line|=filters.