Most Neo4j incidents are visible in the metrics ten to thirty minutes before anyone opens a ticket. Page cache hit ratio drifts down, checkpoint duration creeps up, a nightly job starts holding transactions open, and then one Monday a dashboard query times out and everybody is reading logs after the fact.
Our Neo4j health check checklist is a point-in-time audit. This tutorial is the continuous version: get metrics out of Neo4j into Prometheus, get useful query logs, build a dashboard that shows the four things that actually predict trouble, and set alert thresholds that do not page you for noise.
What we are building
- Neo4j exposing Prometheus metrics on
:2004 - Prometheus scraping it every 15s
- Grafana with four panels: latency, page cache, transactions, and checkpoint/GC health
- A small set of alert rules with defensible thresholds
- Structured query logging you can actually search after the fact
Everything below assumes a self-managed Neo4j 2025.x/2026.x instance (Enterprise for the full metric set; Community exposes a reduced one). If you are on Aura, read on and then see the Aura section at the end — you get metrics through the console and API instead, but the interpretation is identical.
Step 1 — turn on the Prometheus endpoint
In neo4j.conf:
# Metrics: enable the Prometheus scrape endpoint
server.metrics.enabled=true
server.metrics.prometheus.enabled=true
server.metrics.prometheus.endpoint=0.0.0.0:2004
# Prefix every metric so multiple instances are distinguishable
server.metrics.prefix=neo4j
# Per-database and JVM detail (the useful stuff)
server.metrics.filter=*
server.metrics.filter=* is deliberate for a first pass — you cannot tune what you cannot see. Once you know which series your dashboards use, narrow it (for example neo4j.database.*,neo4j.dbms.pool.*,neo4j.vm.*) to cut Prometheus cardinality.
Restart, then confirm:
curl -s localhost:2004/metrics | grep -c '^neo4j_'
curl -s localhost:2004/metrics | grep 'page_cache_hit_ratio'
Metric names are the dotted Neo4j names with dots turned into underscores, so neo4j.database.neo4j.page_cache.hit_ratio arrives as neo4j_database_neo4j_page_cache_hit_ratio. Note that the database name is baked into the metric name rather than exposed as a label — that is the most annoying thing about this integration, and it is why multi-database dashboards need regex matchers or one row per database.
In Docker, the same config as environment variables:
services:
neo4j:
image: neo4j:2026.1-enterprise
environment:
NEO4J_ACCEPT_LICENSE_AGREEMENT: "yes"
NEO4J_server_metrics_prometheus_enabled: "true"
NEO4J_server_metrics_prometheus_endpoint: "0.0.0.0:2004"
NEO4J_server_metrics_filter: "*"
ports: ["7474:7474", "7687:7687", "2004:2004"]
If you built your local instance with our Docker getting-started tutorial, adding those three lines is enough to follow along on a laptop.
Step 2 — scrape it
# prometheus.yml
scrape_configs:
- job_name: neo4j
scrape_interval: 15s
static_configs:
- targets: ["neo4j-core-1:2004", "neo4j-core-2:2004", "neo4j-core-3:2004"]
labels:
cluster: prod
15s is a good default. The metric registry is cheap to read, but do not go below 10s on a busy cluster — every scrape adds a walk of the registry.
Step 3 — the four panels that matter
Skip the sixty-panel dashboard. Start with four signals, each mapping to a distinct failure mode.
Panel 1: query latency and query volume
# queries executed per second, per instance
sum by (instance) (rate(neo4j_database_neo4j_db_query_execution_success_total[5m]))
# failures — should be flat and near zero
sum by (instance) (rate(neo4j_database_neo4j_db_query_execution_failure_total[5m]))
Latency comes from neo4j_database_neo4j_db_query_execution_latency_millis, which is exported with quantiles. Chart p50 and p99 on the same axis. A p99 that separates from p50 by more than an order of magnitude means a small class of queries is doing something structurally different — usually a missing index or an accidental cartesian product. That is a PROFILE plan problem, not a capacity problem, and adding RAM will not fix it.
Panel 2: page cache hit ratio
neo4j_database_neo4j_page_cache_hit_ratio
neo4j_database_neo4j_page_cache_usage_ratio
rate(neo4j_database_neo4j_page_cache_evictions_total[5m])
Hit ratio is the best single proxy for "is my working set in memory". Steady state above 0.98 on an OLTP graph is healthy. Sustained below ~0.90 with a non-zero eviction rate means you are reading from disk on the hot path: either the page cache is undersized for the store, or a scan-heavy job (analytics, a bulk export, an unindexed MATCH (n:Label)) is sweeping the cache and evicting everything the transactional workload needs.
The diagnostic that separates those two: does the dip correlate with a scheduled job? If it does, isolate the job — a read replica, a separate database, or serverless GDS sessions — instead of buying memory.
Size the cache against the real store, not guesswork:
neo4j-admin server memory-recommendation
Panel 3: transactions, and specifically the open/rollback pair
neo4j_database_neo4j_transaction_active
neo4j_database_neo4j_transaction_peak_concurrent_total
rate(neo4j_database_neo4j_transaction_rollbacks_total[5m])
rate(neo4j_database_neo4j_transaction_committed_total[5m])
Two patterns to watch for. First, transaction_active with a rising floor: a client is opening transactions and never closing them (a driver session leaked in a request handler is the classic). Neo4j must retain the store state those transactions can still see, so your store grows even when nothing is being written. Second, a rollback rate that is a meaningful fraction of commits — usually lock contention or deadlocks from concurrent writes hitting the same supernodes.
Chart store size on the same panel so growth-without-writes is obvious:
neo4j_database_neo4j_store_size_total
Panel 4: checkpoints, log rotation, and the JVM
neo4j_database_neo4j_check_point_duration
rate(neo4j_database_neo4j_check_point_events_total[15m])
rate(neo4j_database_neo4j_transaction_log_rotation_events_total[15m])
neo4j_vm_gc_time_millis
neo4j_vm_heap_used
neo4j_dbms_pool_bolt_used
Checkpoint duration climbing over weeks is a slow-motion outage: checkpoints compete with your workload for IO, and a long checkpoint lengthens recovery after an unclean shutdown — which is exactly the number you promised in your restore and PITR drill. GC time spiking with heap near max means the heap is undersized or a query is materialising huge intermediate results (collect() over an unbounded match is the usual culprit).
On a cluster, add a replication view so you can tell a slow follower from a slow database:
neo4j_database_neo4j_cluster_raft_replication_lag
neo4j_database_neo4j_cluster_store_copy_pull_updates_total
Step 4 — structured query logging
Metrics tell you that something is slow. The query log tells you which query. Enable it and keep it parseable:
db.logs.query.enabled=INFO
db.logs.query.threshold=1s
db.logs.query.parameter_logging_enabled=false
db.logs.query.obfuscate_literals=true
db.logs.query.page_logging_enabled=true
db.logs.query.plan_description_enabled=false
Two of those deserve a decision rather than a default:
db.logs.query.threshold=1s— log only queries over a second. Drop it to0temporarily when hunting a pattern, never permanently on a busy system.obfuscate_literals=truewithparameter_logging_enabled=false— if the graph holds personal data, inlined literals and parameter values in a log file are a data-protection problem. Obfuscate, and rely on the query shape plus parameter keys.
Neo4j's log output is configured with Log4j2 (conf/user-logs.xml). Switching the query.log appender to a JSON layout gives you one object per query with elapsedTimeMs, pageHits, pageFaults, allocatedBytes, database and username. pageFaults per query is the field almost nobody looks at and the fastest way to identify the query thrashing your page cache in Panel 2.
For live triage, nothing beats:
SHOW TRANSACTIONS
YIELD transactionId, currentQuery, elapsedTime, status, clientAddress
WHERE elapsedTime > duration('PT5S')
RETURN transactionId, clientAddress, status, elapsedTime, currentQuery
ORDER BY elapsedTime DESC;
and then, once you have found the offender:
TERMINATE TRANSACTION 'neo4j-transaction-123';
Put both in a runbook. Handing a team two commands they can run at 2am is worth more than another dashboard.
Step 5 — alerts with defensible thresholds
Alert on symptoms users feel, plus a small number of leading indicators. Everything else stays on the dashboard.
groups:
- name: neo4j
rules:
- alert: Neo4jQueryFailuresElevated
expr: sum(rate(neo4j_database_neo4j_db_query_execution_failure_total[5m])) > 0.5
for: 10m
labels: { severity: page }
- alert: Neo4jPageCacheHitRatioLow
expr: neo4j_database_neo4j_page_cache_hit_ratio < 0.90
for: 30m
labels: { severity: ticket }
- alert: Neo4jTransactionsStuckOpen
expr: min_over_time(neo4j_database_neo4j_transaction_active[30m]) > 20
for: 30m
labels: { severity: ticket }
- alert: Neo4jCheckpointDurationRising
expr: neo4j_database_neo4j_check_point_duration > 60000
for: 1h
labels: { severity: ticket }
- alert: Neo4jGcPressure
expr: rate(neo4j_vm_gc_time_millis[5m]) > 100
for: 15m
labels: { severity: page }
Note the min_over_time on stuck transactions: that is what distinguishes a rising floor (a leak) from a legitimate burst of concurrency. Alerting on the raw gauge would page you every time a batch job ran.
Two rules we apply on every engagement:
- Every alert needs a runbook line — what to look at, what to run, who to call. An alert without one gets muted inside a month.
- Tune
for:before you tuneexpr:. Most false pages are duration problems, not threshold problems.
What changes on Aura
Aura does not hand you a Prometheus endpoint on port 2004. Metrics are available in the console, and on the dedicated tiers through the Aura API, which you can pull into Prometheus or your APM. Interpretation is unchanged, but three things move:
- Page cache and heap are not yours to tune. The lever is instance size, so page-cache alerts become capacity-planning inputs rather than config changes.
- Query logs come from the console/API rather than a file on disk. Ship them somewhere you control if you need long retention.
- Checkpoint and GC internals are mostly hidden. Lean harder on query latency, failure rate and store growth.
Our Aura and managed cloud practice covers wiring this up without a bastion host.
A 30-minute starting point
If you do nothing else from this tutorial:
- Enable
server.metrics.prometheus.enabledand scrape it every 15s. - Chart four series: query p99 latency,
page_cache_hit_ratio,transaction_active,check_point_duration. - Turn on the query log at a 1s threshold with literal obfuscation.
- Add two alerts: query failures elevated, and GC pressure.
- Paste the
SHOW TRANSACTIONS/TERMINATE TRANSACTIONsnippets into your on-call doc.
That is 80% of Neo4j observability and it takes an afternoon. The remaining 20% — per-workload SLOs, cluster-aware alerting, capacity models tied to store growth — is worth doing once the basics have been quiet for a month.
Need this running on your cluster? GraphGuru's senior Neo4j consultants build monitoring, alerting and on-call runbooks for production graph deployments, self-managed and Aura alike. Get in touch with your version, topology and workload shape, and we will tell you what we would instrument first.