Alerting¶
A scheduled pipeline has an expected shape: it runs on a cadence, reads about as much as last time,
and takes about as long. The useful alerts compare each run with the runs before it. Flare's
metrics carry no run or application id, so once the per-run instance label is aggregated away,
successive runs of a service share the labels the rules compare on. Each run still has series of
its own, which is why the rules below look for series that appeared rather than using
increase().
The repository ships example Prometheus rules,
alerting/flare-rules.yml,
for Prometheus, the Mimir ruler or Grafana alerting. They are starting points. Every threshold
below depends on the workload, and a rule that fires constantly is worse than none, so read why
each one is set the way it is before relying on it.
Outcomes¶
These use the outcome metrics: one point per job and one per application.
| Alert | Fires when | Notes |
|---|---|---|
FlareApplicationFailed |
The latest run that ended in the last hour had a failed job, or was killed mid-job | Spark reports no result for an application, so Flare's application.result is FAILED when any job failed or was still running when it ended, as when a timeout kills it. Only the latest run counts, ordered by flare.application.end_time: when an orchestrator retries a failed run and the retry succeeds, the alert resolves. An application that fails outside any job is not caught |
FlareJobFailed |
A job failed in the last hour, per query | For pipelines that catch a failed job and carry on |
FlareQuerySlowerThanUsual |
A query's p95 job duration in its latest runs is more than twice its p95 over the previous week | Needs a week of history. Queries are told apart by sql.description, so give yours descriptions with setJobDescription |
FlareInputVolumeDropped |
A query read less than half the bytes it read at the same time the day before, or ran and read nothing at all | The classic silent failure: an upstream that delivered a partial or empty drop, and a load that succeeded on it. A stage that reads nothing records no input bytes, so an empty run is caught separately: the query ran, read input yesterday, and has none today. Compares finished runs only, so it fires once a run is done, not while it is still reading; an application that never ends is never compared. Assumes a daily schedule; change offset 1d for other cadences |
FlareApplicationFailed treats each Spark application as one run. That holds when each run gets
its own JVM: spark-submit, Airflow, a Databricks task on its own cluster. It does not hold where
one SparkContext serves many runs: a Databricks shared job
cluster runs every task and
retry of a job run in one application, and an all-purpose cluster, Spark Connect or the Thrift
server serve many. There, a recovered retry still leaves the application FAILED: take run outcomes
from the orchestrator, and use FlareJobFailed for which query failed.
A run that did not happen¶
The first question of all, whether last night's run happened, needs the pipeline's service name, so it is not in the rules file: shipped with a placeholder, it would fire as soon as it was loaded. Add one per pipeline, with the absence window a little longer than the schedule:
- alert: NightlyEtlDidNotRun
expr: |
absent_over_time({__name__=~"flare_application_duration(_seconds)?_count", job="nightly-etl-driver"}[26h])
labels:
severity: critical
annotations:
summary: "nightly-etl has not finished a run in 26 hours"
job is the driver's service.name. The metric is recorded when the application ends, so this
fires for a run that never started and for one that is still running past its window.
Diagnoses¶
| Alert | Fires when |
|---|---|
FlareExecutorsLost |
More than two executors were removed in 15 minutes, for any reason other than a dynamic-allocation scale-down, counted across reasons. flare_executor_removed has the reason of each |
There is no skew rule. Skew only means something within one stage, and the task metrics carry no stage label, since a label per stage creates a series per stage (#136). Across a whole application, which mixes small and large stages, the slowest tasks routinely take more than ten times the median: on a lab TPC-H run with nothing wrong, the ratio was 15.9. Per-stage skew is a search over task spans, below.
On the spans¶
Several diagnoses are only on the spans, as stage attributes, which alert rules cannot read. They
are TraceQL searches in Tempo. The two TraceQL metrics queries below, using count_over_time and
quantile_over_time, need a Tempo version with TraceQL metrics; on Tempo 2.6, which the
local stack runs, that also means the metrics generator's
local-blocks processor, without which Grafana reports "localblocks processor not found". Check
Tempo's documentation for your version.
| Diagnosis | TraceQL |
|---|---|
| Stages that spilled to disk | { span.spark.stage.disk.spilled_bytes > 0 } |
| GC-bound stages | { span.spark.stage.jvm.gc_time_ms > 60000 }, then compare with spark.stage.executor.run_time_ms on the span |
| Executor starvation | { span.spark.stage.scheduler.delay_ms > 30000 }: the stage's tasks waited for executor slots |
| Retried tasks | { name = "spark.task.executor" && span.spark.task.attempt.id > 0 } (needs task spans) |
| Failed stages | { name =~ "spark.stage.*" && status = error }, with the reason in spark.stage.failure_reason |
| A query whose plan changed | Below: more than one fingerprint for one query means it started planning differently |
| Partition skew, per stage | Below: the stages whose slowest tasks take far longer than their median (needs FLARE_TRACE_GRANULARITY=all, which puts the stage id on task spans) |
The plan-change search is a TraceQL metrics query, counting one query's executions by plan shape:
{ span.spark.sql.description = "load orders" } | count_over_time() by (span.spark.sql.plan.initial.fingerprint)
It groups by the plan as Spark first planned it. The plan after AQE's re-planning can differ
between two runs of the same query, so grouping by spark.sql.plan.fingerprint would report changes
that did not happen; see Plan fingerprints.
Skew is the task duration's p99 against its median, per stage. Stage ids restart in every application, so run it for one service over one run's time range; a range covering two runs mixes their stages:
{ resource.service.name = "nightly-etl-executor" && name = "spark.task.executor" } | quantile_over_time(duration, .5, .99) by (span.spark.stage.id)
How the rules read the metrics¶
- Both naming styles. Names are matched with or without the unit
(
flare_job_duration_milliseconds_bucketorflare_job_duration_bucket), as the dashboard does; see In Prometheus. - One set of series per run. Each run is a new JVM with its own
instance, fromservice.instance.id, so its series start with the first value they report.increase()reads zero on a series that starts that way, so the outcome rules test whether a series appeared in the window, withmax_over_time, instead. If you set a fixedservice.instance.id, runs share series andincrease()is the right function. - Tested.
alerting/flare-rules.test.ymlholds promtool unit tests, which CI runs:promtool test rules alerting/flare-rules.test.yml.