The cluster terminated, and the evidence went with it
When a Databricks job fails, the first instinct is to check the driver log. If the cluster was configured with autoscaling and the job used a job cluster (not an interactive one), that cluster terminates within minutes of the failure. The driver log goes with it.
Databricks retains driver logs for terminated clusters, but only for 30 days via the cluster UI, and only if you navigate to the specific cluster ID. The Jobs API endpoint 2.1/jobs/runs/get-output returns the error message and a truncated notebook output, not the full stderr. If your failure was a Java stack trace buried 400 lines deep in the driver log — an OOM on a shuffle spill, a Delta ACID conflict, a network timeout to an external catalog — the truncated output tells you almost nothing.
Cluster event logs capture lifecycle events: DRIVER_HEALTHY, DRIVER_NOT_RESPONDING, AUTOSCALING, INIT_SCRIPTS_FAILED. These survive cluster termination and are accessible via the 2.0/clusters/events API. But they describe infrastructure, not application logic. You know the driver stopped responding. You do not know whether it was a bad UDF, a skewed join, or a credential expiry.
The gap between 'the job failed' and 'here is why' is where most debugging time is spent. Teams that store driver logs to a persistent location — DBFS, cloud storage via a custom log4j appender, or cluster log delivery configured in the cluster spec — close this gap. Teams that do not are left refreshing the Spark UI on a cluster that no longer exists.
For any production job, the cluster spec should include cluster_log_conf pointing to an S3, ADLS, or GCS path. This is a one-line configuration that saves hours of forensic work. Without it, you are investigating a crime scene that gets demolished 10 minutes after the incident.
Job run metadata tells you what happened, not what it broke
The Databricks Jobs API returns structured metadata for every run: run_id, state, start_time, end_time, error_code, and result_state. The error_code field uses values like INTERNAL_ERROR, INVALID_PARAMETER_VALUE, RESOURCE_DOES_NOT_EXIST, and RUN_EXECUTION_ERROR. These are broad categories. RUN_EXECUTION_ERROR covers everything from a syntax error in your notebook to a Delta table schema mismatch to a transient cloud storage timeout.
The real problem is not the error classification — it is the absence of dependency context. Databricks Workflows supports task dependencies within a single job (task A must complete before task B), but it has no native concept of cross-job dependencies. If Job A writes to catalog.schema.silver_orders and Job B reads from it, nothing in the Jobs API connects these two. Job B will run on schedule whether Job A succeeded, failed, or never started.
You can query run history programmatically with 2.1/jobs/runs/list filtered by job_id, but correlating failures across jobs requires you to build that graph yourself. Some teams use Delta table properties (TBLPROPERTIES) to stamp last-write timestamps and check them at the start of downstream jobs. Others use Unity Catalog lineage, which tracks read/write relationships at the table level — but only for operations that go through Unity Catalog, and with a delay that makes it unsuitable for real-time alerting.
The practical result: a job fails at 2:47am, four downstream jobs run successfully on stale data between 3:00am and 4:00am, and the first human to notice is a stakeholder at 9am wondering why yesterday's revenue number has not changed. The failure was detected. The blast radius was not.
Silent success on stale inputs is worse than a loud failure
A failed job creates a Slack alert (if you configured one). A job that succeeds on stale data creates nothing. This is the scenario that actually costs teams credibility with stakeholders.
Consider a common Databricks pipeline pattern: an ingestion job lands raw data into a Bronze Delta table every hour, a transformation job reads Bronze and writes Silver every two hours, and a dbt or notebook-based job builds Gold aggregates on a daily schedule. If the ingestion job fails at 1am, the 2am transformation job reads the same Bronze data it read at midnight. It succeeds. The schema is valid. The row counts look normal. The only signal that something is wrong is the max(event_timestamp) in the Bronze table, which nobody checks programmatically.
Building freshness assertions into your jobs is straightforward but rarely done by default. A simple pre-flight check queries DESCRIBE HISTORY catalog.schema.bronze_orders LIMIT 1 and compares the timestamp column against a threshold. If the last write was more than 90 minutes ago, the job fails fast with a clear message instead of producing silently stale output.
Delta Lake's DESCRIBE HISTORY command returns the full transaction log — every commit, its timestamp, the operation type, and the metrics (rows written, files added). This is the cheapest freshness signal available. It requires no external tooling, runs in milliseconds, and works on any Delta table in Unity Catalog or the legacy Hive metastore.
The harder problem is deciding thresholds. An hourly table that is 91 minutes stale might be fine during a known maintenance window and catastrophic during quarter-close. Static thresholds generate noise. Dynamic thresholds based on historical write frequency require you to maintain that history somewhere — which brings you back to the observability problem.
Cluster event logs reveal infrastructure failures that job metadata hides
When a job fails with INTERNAL_ERROR and the driver log says nothing useful, cluster event logs are the next place to look. The 2.0/clusters/events endpoint returns events for a given cluster_id with pagination support. The events that matter most for debugging are INIT_SCRIPTS_FAILED, DRIVER_NOT_RESPONDING, SPARK_EXCEPTION, DBFS_DOWN, and METASTORE_DOWN.
INIT_SCRIPTS_FAILED is particularly common and particularly opaque. If your cluster uses init scripts to install Python packages, configure networking, or mount storage, a failure in any script causes the cluster to enter a TERMINATED state with termination_reason.code = INIT_SCRIPT_FAILURE. The termination_reason.parameters field contains the script path and exit code but not the script's stderr output. You need the cluster log delivery path (cluster_log_conf) to see what actually went wrong — and if you did not configure it, the output is gone.
Another common pattern: DRIVER_NOT_RESPONDING followed by TERMINATED with reason DRIVER_UNREACHABLE. This happens when the driver node runs out of memory, often because a collect() call or a broadcast join pulled too much data onto a single node. The Spark UI (if the cluster is still alive) shows the memory usage under the Executors tab. But for a terminated job cluster, you need the GC logs or the Spark event logs — both of which require cluster log delivery to persist.
The Jobs API, the Clusters API, and the cluster event log each hold a piece of the story. No single endpoint gives you the full picture. Teams that script a post-failure diagnostic routine — pulling run output, cluster events, and persisted logs into a single incident record — recover faster than those who click through three different UI tabs trying to reconstruct what happened.
MetricSign connects to the Databricks Jobs API and correlates job failures with downstream pipeline state, grouping related incidents and surfacing which downstream consumers ran on stale data after an upstream failure. Instead of discovering the blast radius at 9am, you see it at 2:48am — one minute after the root cause.
Building the trail: a practical checklist for production jobs
The difference between a 10-minute investigation and a 2-hour one comes down to whether you configured observability before the failure happened. Here is what matters.
First, enable cluster log delivery on every job cluster. In the job JSON spec, add cluster_log_conf with a destination path in your cloud storage. Databricks writes driver logs, executor logs, and init script output to this path within 5 minutes of cluster termination. The storage cost is negligible — a typical job produces 1-10 MB of logs per run.
Second, use structured streaming or Delta Change Data Feed for inter-job contracts instead of implicit table reads. If Job B depends on Job A's output, Job B should assert that the output is fresh before proceeding. A three-line SQL check against DESCRIBE HISTORY is sufficient. Fail the job explicitly rather than producing stale results silently.
Third, tag your job runs with metadata. The Jobs API supports idempotency_token and you can pass custom parameters via notebook_params or python_params. Stamping each run with a correlation ID, the git commit SHA of the notebook, and the expected input table versions makes post-mortem investigation dramatically faster.
Fourth, retain run outputs beyond the 30-day default. The 2.1/jobs/runs/export endpoint lets you pull notebook output as HTML. A scheduled cleanup job that archives these to cloud storage costs almost nothing and has saved multiple teams from losing the only readable record of what a failed run actually produced.
Finally, script your diagnostic routine. When a job fails, automatically pull the run output, the cluster events for the associated cluster_id, and the last 100 lines of the driver log from cloud storage. Assemble them into a single document. The goal is not to automate the fix — it is to eliminate the 45 minutes of tab-switching and API-calling that happens before anyone starts thinking about the actual problem.