coralogix-metrics-query
PromQL fundamentals and metric discovery/query workflow. Load when you need to explore metrics in your investigation or to answer a question about metrics. For service-level RED metrics (error rate, l
SKILL.mdskills/coralogix-metrics-query/SKILL.md
GitHubMetrics Skill - Analyzing metrics via Coralogix/PromQL
This skill describes how to discover and analyze metrics using your available tools and explains how to use PromQL for that goal.
Core Workflow:
1. Initial Assessment
When a user reports a problem (e.g., "I have production issues"):
- If the problem is vague or broad, ASK 1-2 CLARIFYING QUESTIONS to get specifics
- Focus on: What exactly is failing? When did it start? What symptoms are users seeing?
- ONLY ask clarifying questions if absolutely necessary - prefer to start investigating immediately
2. Problem Decomposition
Once you understand the issue (e.g., "users get HTTP error unavailable for the last 30 minutes"):
- Break down the problem into SMALL, SPECIFIC questions that can each be answered with a single metric query
- Think systematically about layers: Application → Services → Infrastructure → Dependencies
- Generate questions like:
- "Are there any K8S deployments down in the last 30 minutes?"
- "What's the p99 latency in the ELB?"
- "Are there any pods with high restart counts?"
- "What RDS instances exist and are they healthy?"
3. Metric Discovery
- ALWAYS start by using
search_relevant_metricstool to find metrics related to the subject the user is measuring (e.g. "requests", "errors", "latency") — NOT the dimension they want to slice by (e.g. "user_email", "account_id"). If that metric doesn't carry the dimension, see "Handling a missing filter label" below — but don't go looking there unless a needed label is actually missing. - Analyze the returned metrics and their descriptions to identify the most appropriate ones
- If no relevant metrics are found, try alternative search terms or broader keywords
- When two metric names differ only by a
_count(or_sum) suffix, prefer the base metric. This follows Prometheus naming conventions:_countand_sumare the components a histogram or summary emits, where_countis the number of observations recorded — not the quantity being measured. Treat this as a heuristic, not a hard rule: if the user explicitly wants the number of observations,_countis the correct choice. - Match the metric to what the user is measuring, not to whichever metric happens to carry their filter label. Identify the action or subject in the user's question (e.g. "requests served", "errors", "logins", "bytes sent") and pick the metric whose description reflects that outcome — even if a different metric is the one that exposes the label they want to slice by. Example: if the user asks "how many requests came from premium accounts?" and
http_requests_totalmeasures requests but lacks anaccount_tierlabel, whilesessions_totalhasaccount_tierbut counts sessions, still measure withhttp_requests_totaland bring in the label via a join (see "Handling a missing filter label" below). Switching tosessions_totaljust because it has the label silently changes what you're counting (sessions ≠ requests). However you discover the bridge metric, the dimension filter must be applied inside a single PromQLon()/group_left()join — never by running separate queries and combining the numbers in prose.
4. Query Construction & Execution
- Based on the discovered metrics and labels, construct appropriate PromQL queries
- Use query_promql_instant for current state queries (e.g., "What is the current status?"), or single value queries (e.g., "How many users were active yesterday?")
- IMPORTANT: You can use the
timeparameter to retrieve data from absolute dates.
- IMPORTANT: You can use the
- Use query_promql_range for queries that require comparison of different time periods (e.g., "What changed in the last hour?", "How many users were active per day in the last week?")
- IMPORTANT: for the 'start' and 'end' parameters, use the 'now' keyword to get the current time, and use relative time like 'now-1h' to get the time 1 hour ago.
- IMPORTANT: utilize the
stepparameter to control the resolution, when the resolution is too high, the query will return too many results, which can't be displayed or consumed.
- Start with simpler queries and build complexity as needed. If the query fails, analyze the error message, fix the query, and retry.
You may check if an existing dashboard already tracks the same concept — reusing a known-good query is faster and keeps analysis consistent with existing monitoring:
search_queries(search_type="semantic", query_text="p99 latency")— find queries by meaningsearch_queries(search_type="by_field", field_path="k8s_pod_cpu_usage")— find queries referencing a metric name
5. Result Validation & Retry Logic
- If a query returns no results or fails validation:
- Analyze what might be wrong (syntax, metric name, labels, time range)
- Try alternative metric names or adjust the query structure
- Consider broader or narrower filtering criteria
- Maximum 5 retry attempts with improvements each time
- If the results return with empty labels, and the question requires a specific label, try refining your query to filter out null values for the label.
- Example: If the question is "Which deployment has the highest CPU usage?", and you used the query
max by (k8s_deployment_name) (rate(k8s_pod_cpu_usage[5m])), you should refine the query tomax by (k8s_deployment_name) (rate(k8s_pod_cpu_usage{k8s_deployment_name!=""}[5m]))
- Example: If the question is "Which deployment has the highest CPU usage?", and you used the query
- If still no results after retries, explain what was attempted and suggest alternatives
6. Pattern Recognition and Root Cause Analysis
- Look for correlations across different metrics
- Identify temporal patterns, like high loads on a recurring basis, or spikes in error rates, etc.
- Identify potential root causes based on the evidence
- Provide actionable next steps for resolution
Handling a missing filter label (cross-metric joins)
This is an uncommon case — most questions are answered by a single metric, so skip this section entirely when your measurement metric already carries the labels the user wants to filter or group by. Only when a label the user wants is not present on your measurement metric do you need to join to a "bridge" metric that has it.
Terminology (the four roles in the join pattern below):
- Measurement metric — the metric that answers what you're counting/measuring (e.g. requests, tokens, sessions). It drives the value in the result.
- Dimension — the label the user wants to filter or group by (e.g.
plan,user_email,account_slug) that is missing from the measurement metric. - Join key — a label present on both metrics that links their series (typically
user_id,account_id,service,pod,tenant_id). - Bridge metric — a metric that carries both the missing dimension AND the join key; it projects the dimension onto the measurement metric.
Flow:
When is by_label required? by_label discovery (steps 1–2) is OPTIONAL only when a single metric in your earlier search_relevant_metrics results (whichever mode you used) already lists labels that include both (i) the missing dimension and (ii) a proper join key shared with your measurement metric. In that case the bridge is already fully confirmed — skip to step 3 and build the join. OTHERWISE by_label is MANDATORY: if no by_description metric exposes both labels (the common case — by_description ranks by measurement subject, so the bridge usually isn't in those results, or only shows a partial label set), you MUST resolve the bridge with search_relevant_metrics(<dimension>, mode="by_label", join_key_label=<join_key>) before building the join. Do not infer a bridge from a metric that shows only one of the two labels. Step 3 (the join itself) is never optional.
Choosing the join key. The join key must be a label present on both the measurement metric and the bridge. When several shared labels are candidates, pick the one whose entity granularity matches the dimension — the identifier for the same entity the dimension describes:
user_email(a per-user attribute) → join onuser_id, notteam_name/account_id(coarser — over-aggregates and misattributes across users) and notpod_name/instance(infra, a different entity — usually won't even co-occur with the dimension).- Match the noun:
user_*dimensions ↔ auser_*key;account_*↔ anaccount_*key;route/http_route↔ anhttp_*/routekey. - Prefer the most specific (closest-granularity / highest-cardinality) shared identifier. Pass that label as
join_key_labelso theby_labelresult is restricted to metrics that carry the dimension AND that key on the same series (guaranteed bridges).
- Find candidate label names. Call
search_labels(<dimension_substring>). This returns label names containing the substring. Use a short, distinctive substring (e.g.email, not the full phrase).search_labelsqueries the current, account-wide label index (it is intentionally NOT pinned to the investigation's time window, since label names are schema, not data). It reliably surfaces high-cardinality, application-specific labels such asuser_email,user_id,route, andtenant_id— trust its results.- If
search_labelsreturns nothing for a dimension you're confident exists, the label is almost certainly only present on metrics that aren't currently active (the index reflects the present schema). Don't get stuck — proceed with either:- (a) Re-read the
search_relevant_metricsoutput from step 3 — each metric entry already lists its labels. Find the metric that exposes your dimension there. - (b) Call
search_relevant_metrics(<canonical_label>, mode="by_label")directly with the canonical name (e.g.user_email,user_id,route/http_route). Theby_labelmode lists the distinct metrics that carry the label (deduplicated server-side), so a real label is confirmed (a wrong guess returns no metrics) — and if the result is truncated, narrow it with thename_filterargument.
- (a) Re-read the
- Find candidate bridge metrics. For each promising label name (usually 1), call
search_relevant_metrics(<dimension>, mode="by_label", join_key_label=<join_key>), passing the join key you chose above. Withjoin_key_labelset, the result is restricted to metrics whose series carry both the dimension and that join key — every metric returned is therefore a usable bridge (no separateget_metric_labelscross-check needed). A standalone metric is shown by its full name (e.g.requests_totalorup). Suffix variants of one logical metric are collapsed into a metric family with their suffixes in parentheses, e.g.http_request_duration (_bucket, _sum, _count); when the bare base metric also exists alongside its variants you'll seebase (plain, _total)— theplaintag meansbaseitself is directly queryable. Pick the concrete name when you build the query (_total+rate()for counters,_bucketfor histograms).- Empty result? It means no single metric carries both the dimension and that join key on the same series. Either (a) retry with a different join-key candidate (another shared label), or (b) drop
join_key_labelto list every metric exposing the dimension and inspect the candidates' labels withget_metric_labels. If still nothing pairs the dimension with a shared key, no bridge exists (see Caveats). - Omitting
join_key_labelfalls back to listing all metrics that expose the dimension. The list may then be truncated for labels carried by many metrics: only when the output ends with a truncation hint, re-call withname_filterset to a metric-name substring to narrow to the family you care about.
- Empty result? It means no single metric carries both the dimension and that join key on the same series. Either (a) retry with a different join-key candidate (another shared label), or (b) drop
- Build the PromQL join. The canonical pattern:
Then add the user's actual filter on<measurement> * on(<join_key>) group_left(<dimension>) <bridge>{<dimension>!=""}<dimension>(e.g.=~,!=).- Counting distinct entities (very common): when the user asks "how many distinct X", you must collapse cardinality with an explicit
count by. Otherwisecount(...)will count series (often dozens per entity due to other labels likeaccount_id,route,status_code) and inflate the answer by an order of magnitude.- Pattern:
count(count by (<join_key>) (<join expression> > 0))
- Pattern:
- Never stitch in prose. Do NOT answer a cross-metric question by running one query for the dimension population and a separate query for the measurement, then combining the counts in text. The dimension filter MUST live inside one PromQL expression via
on(<join_key>) group_left(<dimension>). If your written answer claims "users/accounts who also had activity" but no query you ran containedgroup_left, you did not actually join — redo it as a single joined expression. - Magnitude sanity check. A distinct-entity count produced by a join is bounded by activity on the measurement metric and is therefore ≤ the size of the filtered bridge population. If your result comes out roughly equal to the whole filtered bridge population (e.g. every user who merely opened a chat, every account that merely exists), you almost certainly counted the bridge alone and skipped the join — recompute with the
on()/group_left()expression before answering.
- Counting distinct entities (very common): when the user asks "how many distinct X", you must collapse cardinality with an explicit
Worked example — "How many distinct users on the premium plan made requests over the last 7 days?"
- Subject is "made requests" →
http_requests_total(request counter). Dimension isplan(filter). - Step 3:
search_relevant_metrics("requests")returns:http_requests_total(labels:user_id, route, ...) — noplan✗sessions_total(labels:user_id, plan, ...) — hasplan✓
- Choice: measure with
http_requests_total, because the user asked about requests (per-request activity), not sessions. Do NOT switch tosessions_totaljust because it hasplan. - The filter label
planis missing from the measurement metric → join (see below). - Join key:
http_requests_totalexposesuser_idandroute. The dimensionplanis a per-user attribute, so the matching-granularity key isuser_id(notroute). search_labels("plan")→[plan, billing_plan]. (If it returns nothing, fall back to the metric labels you already saw in step 3.)search_relevant_metrics("plan", mode="by_label", join_key_label="user_id")→sessions_total, …. Confirmssessions_totalcarries bothplananduser_id, so it's a usable bridge.- Build the join. Right shape for "distinct users":
Wrong shape (counts series, not users):count( count by (user_id) ( increase(http_requests_total[7d]) * on(user_id) group_left(plan) max by (user_id, plan) (sessions_total{plan="premium"}) > 0 ) )count((increase(http_requests_total[7d]) > 0) and on(user_id) sessions_total{plan="premium"})— typically gives a much larger number.
Caveats — this approach does not always work:
- A bridge metric may not exist. Not every dimension is reachable via a join — the data simply may not be there. If
search_relevant_metrics(<dimension>, mode="by_label", join_key_label=<key>)returns nothing for every plausible join key (and a join-key-lessby_labelshows the dimension only on metrics that share no key with your measurement metric), say so and offer the closest partial answer (or a logs/spans alternative) instead of forcing a join or inventing a metric. - A matching label name is not proof of a correct join. Two metrics can both have a
user_id(oraccount_id,service, …) whose values come from different sources/ID spaces, so anon(<key>)join occasionally yields empty or misleading results even when it "type-checks". You don't need to verify every join — trust a plausible result and move on. Only when a join comes back empty or clearly implausible should you suspect the key isn't truly shared and reconsider the bridge/key (a quickget_metric_label_valueson both sides confirms whether values overlap).
Available Tools:
- search_relevant_metrics: Find metrics related to the user's question. Supports three modes via the
modearg:by_description(default): semantic search over a natural-language description of what to measure.by_name: wildcard metric-name pattern matching (e.g.http_*,*_total).by_label: list which metrics expose an EXACT label name (e.g.user_email), to pick a bridge metric for a PromQL join (use aftersearch_labels). Optionalname_filter(a metric-name substring) narrows a truncated result; optionaljoin_key_label(a second label) restricts results to metrics carrying both labels. See "Handling a missing filter label" above for the full workflow.
- query_promql_instant: Use this to query a metric at a specific time
- query_promql_range: Use this to query a metric over a time range
- get_metric_labels: Use this to get the labels of a metric
- get_metric_label_values: Use this to get the values of a specific label for a metric
- search_labels: Find label names matching a substring across all metrics. Cheap discovery call — use when the user mentions a dimension/filter but you don't yet know which metric carries it. Follow up with
search_relevant_metrics(<label>, mode="by_label").
Key Principles:
- Be Proactive: Don't wait for the user to ask specific questions - drive the investigation
- Chain Steps: Use answers from one query to inform the next step
- Think Holistically: Consider all layers of the stack (app, network, compute, storage, dependencies)
- Be Systematic: Follow a logical troubleshooting flow from symptoms to root cause
- Explain Your Reasoning: Help the user understand why you're checking specific metrics
Common Investigation Patterns:
For HTTP Errors:
- Check deployment/pod status
- Check service health and connectivity
- Check resource utilization (CPU, memory)
- Check dependencies (databases, external services)
- Check network and load balancer metrics
For Performance Issues:
- Check resource saturation (CPU, memory, disk, network)
- Check application metrics (latency, throughput, errors)
- Check scaling metrics (pod counts, autoscaling triggers)
- Check dependency performance
For Availability Issues:
- Check pod/deployment readiness and restarts
- Check node health and capacity
- Check service discovery and networking
- Check infrastructure dependencies
PromQL Guidelines
Core Principles
Pick the right API
- Instant queries evaluate an expression at a single timestamp (now, or a given
time). Use when the user wants one number or one vector as of a moment, essentially any query that does not require results over different timeframes. - Range queries evaluate the expression repeatedly across
[start, end]at a givenstep. Use for time series over a period (e.g., daily active users per day).- Important: Note that Range queries evaluate the expression repeatedly at all given step. i.e if you set
step=1d,start=now-1d,end=now, and the query ismax_over_time(metric[1d]), the query will be evaluated 1 time atnow-1d, and 1 time atnow, which means it will evaluate 2 days of data.
- Important: Note that Range queries evaluate the expression repeatedly at all given step. i.e if you set
- Prioritize Instant Queries over Range Queries for most questions, except for questions that require comparing different time frames.
- Instant queries evaluate an expression at a single timestamp (now, or a given
Understand PromQL value types
- Instant vector (set of series with 1 sample each at eval time)
- Range vector (series with many samples over a window
[t-range, t]) - Scalar (single number)
- String (rare)
- Functions like
*_over_time()require a range vector. Aggregations likesum/max/min/avg ... by(...)consume instant vectors. - Important: When using
*_over_time()functions with Range Queries you must be aware that the query will also be evaluated at thestarttime, and include the window specified in the*_over_time()function. - Example: If you use
max_over_time(metric[1d])withstart=now-1d,end=now,step=1d, the query will be evaluated atnow-1dandnow, and the result will be the max of the metric over the window[now-2d, now]. This is a very common mistake, you must avoid! If a user asks "What is the max of x between 2025-01-01 and 2025-01-07?" and you usemax_over_time(x[7d])withstart=2025-01-01,end=2025-01-07,step=1d, the query will be evaluated at2025-01-01and2025-01-07- which is wrong! You must use Instant Query to avoid this mistake.
Separation of concerns
- Use
*_over_time()for temporal reductions across a window (e.g.,max_over_time,avg_over_time,quantile_over_time). - Use
sum/max/min/avg by (...)for label-set aggregation across series at the eval point. - Chain them as needed (temporal reduction first, then label aggregation, or vice versa).
- Use
Counters vs. gauges
- Counters (monotonic, suffixed
_total) → userate()/irate()orincrease()over a window. - Gauges (current value) → use
avg_over_time,max_over_time, etc., or plainavg(...)depending on intent.
- Counters (monotonic, suffixed
Suffix conventions
- Canonical:
_total(counter),_bucket/_sum/_count(histogram),_sum/_count(summary),_created. - Non-standard:
_avg,_mean, etc. Prefer computing averages via PromQL unless exporter dictates otherwise.
- Canonical:
PromQL Tool Usage
Instant Query
Example: absolute max over last 24h (single result)
max by () (max_over_time(http_requests_in_flight[24h]))
Range Query
Example: Absolute max per day over the last 7 days
max by () (max_over_time(metric[1d]))
Set start=now-7d, end=now, step=1d.
IMPORTANT: When using range queries, you must align the step parameter, with any range parameters you use in temporal reduction functions. i.e if you use max_over_time(metric[1d]), you must set step=1d.
PromQL Fundamentals (with examples)
Label Matching & Aggregation
- Matchers:
{label="v"},{label!="v"},{label=~"re.*"},{label!~"re"} - Aggregate by labels to keep them; use without to drop them.
sum by (job) (rate(http_requests_total[5m]))
sum without (instance) (up)
Temporal Reductions (range → instant)
max_over_time(cpu_usage[1h])
avg_over_time(node_memory_Active_bytes[30m])
quantile_over_time(0.99, queue_length[1h])
Counters: Rates, Increases, Windows
- Per-instance RPS:
rate(http_requests_total[5m])
- Total RPS across fleet:
sum by () (rate(http_requests_total[5m]))
- Events in last day (per user, then count actives):
count( sum by (user_id) (increase(api_call_count[24h])) > 0 )
Histograms & Summaries
- p95 from a histogram:
histogram_quantile(
0.95,
sum by (le, route) (rate(http_request_duration_seconds_bucket[5m]))
)
- Average from summary parts:
sum(rate(req_duration_seconds_sum[5m]))
/
sum(rate(req_duration_seconds_count[5m]))
Max over a Period
- Correct:
max_over_time(metric[4d])
- Aggregate across series (absolute max):
max by () (max_over_time(metric[4d]))
- Aggregate across series (absolute max) per label:
max by (label) (max_over_time(metric[4d]))
- Incorrect:
max(metric[4d])(aggregation cannot take a range vector)
Top-k / Ranking
topk(5, sum by (instance) (rate(http_requests_total[5m]))
Common Tasks (ready to adapt)
- Absolute peak per instance over 7d, then pick the winner
topk(1, max by (instance) (max_over_time(my_metric[7d])))
- Global CPU usage % (avg across cores & hosts)
avg by () (
rate(process_cpu_seconds_total[5m])
) * 100
- Error rate (%) per route
100 * sum by (route) (rate(http_requests_total{code=~"5.."}[5m]))
/ sum by (route) (rate(http_requests_total[5m]))
- Daily active users over a week (time series)
- Expression:
count(count by (user_id) (increase(api_call_count[1d]) > 0))
- Use
query_promql_rangewithstep=1d,start=now-6d,end=now. (Note we start from 6 days ago, because the increase function takes the earlier day into account)
Performance & Safety Guidelines
- Prefer short windows for
rate()(e.g., 1-5m) unless data is bursty or sparse. - Avoid unbounded fan-out (e.g., joining massive label sets).
- Keep cardinality under control; aggregate early (
sum by (...)) when you only need totals. - Use
clamp_max/clamp_minto tame outliers when needed. - For histograms, always aggregate buckets (
sum by (le, ...)) beforehistogram_quantile. - Be mindful of counter resets;
rate()/increase()handle resets automatically.
Frequent Gotchas (and fixes)
“Why am I getting steps when I only want one number?” You're using
query_promql_range. Usequery_promql_instantinstead.“
max(metric[...])errors.”max()can't take a range vector. Usemax_over_time(metric[...]), then aggregate withmax by () (...)if needed.“
_over_time(metric[...]) by (label)errors.”_over_timeaggregations cannot include abylabel clause. Usemax_over_time(metric[...]), then aggregate withmax by () (...).“Avg looks wrong for counters.” Counters need
rate()/increase(), notavg_over_time.“p95 from summary?” Summaries expose quantiles directly via the
quantilelabel. For histograms, usehistogram_quantileon bucket rates.
Mini Cheat-Sheet
- Rate of a counter:
rate(x_total[5m]) - Increase last 24h:
increase(x_total[24h]) - Avg of a gauge over 1h:
avg_over_time(x[1h]) - Max over 4d (absolute):
max by () (max_over_time(x[4d])) - Top 5 by RPS:
topk(5, sum by (instance) (rate(x_total[5m]))) - p95 latency (histogram):
histogram_quantile(0.95, sum by (le) (rate(x_bucket[5m]))) - Filter labels:
{env="prod", job=~"api|web"} - Drop a label in agg:
sum without (instance) (x) - Time travel:
expr @ <unix_ts>orexpr offset 1h
Related Skills
- If the question is about a specific service/pod/entity's RED metrics, health, or resource
saturation (CPU/memory/OOMKilled, JVM heap/GC), load the
coralogix-service-catalogskill first — it's a curated, pre-aggregated path for that data rather than hand-writing PromQL. Fall back to this skill for ad-hoc metrics beyond the fixed Service Catalog columns.
MCP tools
query_promql_instant
query: PromQL expressiontime(optional): RFC-3339/ISO 8601 UTC only; omit for current time. Relative strings likenow-1hare not accepted.
query_promql_range
query: PromQL expressionstart/end:now, relative (now-1h,now-6d), Unix timestamp, or RFC-3339/ISO 8601 UTCstep: resolution (e.g.30s,1h,1d)limit: max samples per series (1–200)
get_datetime
Returns the current UTC time as ISO 8601. Use for an explicit time on instant queries; for range queries you can pass now / now-1h directly to start / end.
search_relevant_metrics
query: search term, interpreted permode(description text, name pattern, or EXACT label name)mode:by_description(default) /by_name/by_labeltop_k: max results,by_descriptiononly (1–100, default 5)name_filter(optional,by_labelonly): metric-name substring to narrow a truncated resultjoin_key_label(optional,by_labelonly): EXACT second label (the shared join key); returns only metrics carrying BOTHqueryand this label on the same series (guaranteed bridges)
search_labels
substring: case-insensitive substring matched against label names across all metrics; exact match returned first. Returns one label name per line (with a truncation hint when more match).