This is an automated email from the ASF dual-hosted git repository.

janhoy pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/solr.git


The following commit(s) were added to refs/heads/main by this push:
     new 13c693d805f SOLR-18533: Developer tooling for monitoring (#5109)
13c693d805f is described below

commit 13c693d805f6541f7cec684cd27d28eef7b3b614
Author: Jan Høydahl <[email protected]>
AuthorDate: Sat Oct 24 16:15:17 2026 +0200

    SOLR-18533: Developer tooling for monitoring (#5109)
---
 .gitattributes                                     |   7 +
 .../SOLR-18533-monitoring-dev-tooling.yml          |  14 +
 solr/monitoring/README.md                          | 203 ++++++++++++++
 solr/monitoring/dev/alertmanager/alertmanager.yml  |  86 ++++++
 solr/monitoring/dev/docker-compose.yml             | 155 +++++++++++
 .../grafana/provisioning/dashboards/dashboards.yml |  28 ++
 .../provisioning/datasources/prometheus.yml        |  29 ++
 .../monitoring/dev/prometheus/prometheus-local.yml |  48 ++++
 solr/monitoring/dev/prometheus/prometheus.yml      |  43 +++
 solr/monitoring/dev/stack.sh                       | 130 +++++++++
 solr/monitoring/dev/trafficgen/Dockerfile          |  29 ++
 solr/monitoring/dev/trafficgen/requirements.txt    |   3 +
 solr/monitoring/dev/trafficgen/trafficgen.py       | 298 +++++++++++++++++++++
 solr/monitoring/dev/trafficgen/trafficgen.yml      |  45 ++++
 solr/monitoring/grafana-solr-dashboard.json        | 149 +++++++++++
 solr/monitoring/mixin/.gitignore                   |   2 +
 solr/monitoring/mixin/Dockerfile                   |  55 ++++
 solr/monitoring/mixin/Makefile                     |  86 ++++++
 solr/monitoring/mixin/alerts/alerts.libsonnet      |  51 ++++
 solr/monitoring/mixin/config.libsonnet             |  35 +++
 .../mixin/dashboards/dashboards.libsonnet          | 102 +++++++
 solr/monitoring/mixin/jsonnetfile.json             |  15 ++
 solr/monitoring/mixin/jsonnetfile.lock.json        |  46 ++++
 solr/monitoring/mixin/license-header.yml           |  17 ++
 solr/monitoring/mixin/make.sh                      |  67 +++++
 solr/monitoring/mixin/mixin.libsonnet              |  36 +++
 solr/monitoring/prometheus-solr-alerts.yml         |  30 +++
 .../modules/deployment-guide/deployment-nav.adoc   |   1 +
 .../grafana-solr-dashboard.png                     | Bin 808611 -> 42073 bytes
 .../prometheus-solr-ping.png                       | Bin 425471 -> 0 bytes
 .../solr-exporter-diagram.png                      | Bin 29070 -> 0 bytes
 .../solr-monitoring-diagram.png                    | Bin 0 -> 73028 bytes
 .../solr-monitoring-diagram.svg                    | Bin 0 -> 9482 bytes
 .../deployment-guide/pages/metrics-reporting.adoc  |   2 +
 .../monitoring-with-prometheus-and-grafana.adoc    | 171 ++++++++++++
 35 files changed, 1983 insertions(+)

diff --git a/.gitattributes b/.gitattributes
index 569be426630..8b238566943 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -9,3 +9,10 @@ versions.lock text eol=lf
 /.*               export-ignore
 /dev-docs         export-ignore
 AGENTS.md         export-ignore
+
+# Monitoring mixin generated outputs — always LF to ensure make regeneration 
is idempotent.
+solr/monitoring/grafana-solr-dashboard.json text eol=lf
+solr/monitoring/prometheus-solr-alerts.yml  text eol=lf
+
+# Contributor-only tooling, excluded from the source release like dev-docs.
+solr/monitoring/dev export-ignore
diff --git a/changelog/unreleased/SOLR-18533-monitoring-dev-tooling.yml 
b/changelog/unreleased/SOLR-18533-monitoring-dev-tooling.yml
new file mode 100644
index 00000000000..33d0f76a65e
--- /dev/null
+++ b/changelog/unreleased/SOLR-18533-monitoring-dev-tooling.yml
@@ -0,0 +1,14 @@
+# See https://github.com/apache/solr/blob/main/dev-docs/changelog.adoc
+
+title: >
+  Add tooling for building Solr's Grafana dashboard and Prometheus alert rules 
from jsonnet sources,
+  under `solr/monitoring`, together with a docker-compose stack for testing 
them locally. The
+  dashboard and alert rules shipped so far are placeholders: the real 
dashboard follows in
+  SOLR-18534 and the real alert rules in SOLR-18535.
+type: other
+authors:
+  - name: Jan Høydahl
+    url: https://home.apache.org/phonebook.html?uid=janhoy
+links:
+  - name: SOLR-18533
+    url: https://issues.apache.org/jira/browse/SOLR-18533
diff --git a/solr/monitoring/README.md b/solr/monitoring/README.md
new file mode 100644
index 00000000000..b7131b0a997
--- /dev/null
+++ b/solr/monitoring/README.md
@@ -0,0 +1,203 @@
+<!--
+    Licensed to the Apache Software Foundation (ASF) under one or more
+    contributor license agreements.  See the NOTICE file distributed with
+    this work for additional information regarding copyright ownership.
+    The ASF licenses this file to You under the Apache License, Version 2.0
+    (the "License"); you may not use this file except in compliance with
+    the License.  You may obtain a copy of the License at
+
+        http://www.apache.org/licenses/LICENSE-2.0
+
+    Unless required by applicable law or agreed to in writing, software
+    distributed under the License is distributed on an "AS IS" BASIS,
+    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+    See the License for the specific language governing permissions and
+    limitations under the License.
+ -->
+# Solr Monitoring
+
+This directory provides two ready-to-use monitoring artifacts for Solr that 
you can drop into
+your existing Prometheus + Grafana installation:
+
+| File | Description |
+|---|---|
+| **`grafana-solr-dashboard.json`** | Grafana dashboard — import directly into 
Grafana |
+| **`prometheus-solr-alerts.yml`** | Prometheus alert rules — reference from 
your `prometheus.yml` |
+
+These are the **main artifacts**. Everything else in this directory supports 
their creation
+or testing.
+
+| File / Directory | Description |
+|---|---|
+| `mixin/` | Jsonnet source (single source of truth used to regenerate the 
artifacts above) |
+| `dev/` | **Developer convenience only** — docker-compose stack for testing 
changes to the artifacts |
+
+> **Both artifacts are placeholders right now** — a two-panel dashboard and a 
single alert, which
+> exist to prove the build pipeline works end to end. The real dashboard 
arrives in
+> [SOLR-18534](https://issues.apache.org/jira/browse/SOLR-18534), the alert 
rules in
+> [SOLR-18535](https://issues.apache.org/jira/browse/SOLR-18535), and OTLP 
push support in
+> [SOLR-18536](https://issues.apache.org/jira/browse/SOLR-18536).
+
+For the full integration guide see the Solr Reference Guide:
+**[Monitoring Solr with Prometheus and 
Grafana](../solr-ref-guide/modules/deployment-guide/pages/monitoring-with-prometheus-and-grafana.adoc)**
+
+---
+
+## Importing the Dashboard
+
+1. Open Grafana → **Dashboards → Import**
+2. Upload `grafana-solr-dashboard.json` or paste its contents
+3. Select your Prometheus datasource when prompted
+4. Click **Import**
+
+The placeholder dashboard has two panels, Request Rate and Heap Used, and two 
dropdowns:
+`datasource` and `interval`.
+
+---
+
+## Loading Alert Rules into Prometheus
+
+Add the alert rules file to your Prometheus configuration:
+
+```yaml
+# prometheus.yml
+rule_files:
+  - /path/to/prometheus-solr-alerts.yml
+```
+
+Validate locally before deploying:
+
+```bash
+promtool check rules prometheus-solr-alerts.yml
+```
+
+One placeholder rule is included: `SolrNodeDown`, which fires when a Solr 
target has failed to
+scrape for 2 minutes.
+
+---
+
+## Customizing
+
+All tunable settings live in `mixin/config.libsonnet`:
+
+```jsonnet
+{
+  _config:: {
+    solrSelector: 'job="solr"',   // label selector identifying Solr targets
+    rateInterval: '5m',           // window for rate() queries
+  },
+}
+```
+
+`solrSelector` must match your Prometheus scrape job, or no panel or rule will 
match a series.
+Edit the file and regenerate, or patch `_config` from your own jsonnet without 
touching this tree:
+
+```jsonnet
+(import 'mixin.libsonnet') + { _config+:: { solrSelector: 'job="my-solr"' } }
+```
+
+Every key in `config.libsonnet` is consumed by the dashboard or the alert 
rules; the file grows as
+they do.
+
+---
+
+## Mixin Build
+
+Only contributors who need to **regenerate** the artifacts need build tooling.
+Operators can use the pre-generated files directly.
+
+There are two equivalent ways to build:
+
+### Option A — Docker (no local tool installation required)
+
+```bash
+cd mixin
+./make.sh install   # fetch grafonnet dependency into vendor/
+./make.sh all       # regenerate both artifacts
+./make.sh check     # validate alert rules
+```
+
+`make.sh` automatically builds the `solr-mixin-make:latest` Docker image on 
first run, with all
+tool versions pinned in `mixin/Dockerfile`. Pass `--rebuild` to force a fresh 
image build
+(e.g. after a Dockerfile update).
+
+### Option B — Local tools
+
+Install the required tools once:
+
+| Tool | Purpose | Install |
+|---|---|---|
+| `jsonnet` (go-jsonnet) | Evaluate jsonnet source | `brew install go-jsonnet` 
or `go install github.com/google/go-jsonnet/cmd/jsonnet@latest` |
+| `jb` (jsonnet-bundler) | Manage dependencies | `brew install 
jsonnet-bundler` or `go install 
github.com/jsonnet-bundler/jsonnet-bundler/cmd/jb@latest` |
+| `gojsontoyaml` | Convert JSON→YAML for alert rules | `go install 
github.com/brancz/gojsontoyaml@latest` |
+| `promtool` *(optional)* | Validate alert rules | `brew install prometheus` |
+
+Then build:
+
+```bash
+cd mixin
+make install      # fetch grafonnet dependency into vendor/
+make all          # regenerate both artifacts
+```
+
+A target aborts before touching any output if a tool is missing, and writes 
through a temp file,
+so a failed build cannot leave a half-written artifact behind.
+
+### Makefile targets
+
+Both `./make.sh <target>` and `make <target>` accept the same targets:
+
+| Target | Action |
+|---|---|
+| `install` | Download jsonnet dependencies (grafonnet) into `vendor/` |
+| `dashboards` | Regenerate `grafana-solr-dashboard.json` |
+| `alerts` | Regenerate `prometheus-solr-alerts.yml` |
+| `all` | Regenerate both outputs |
+| `check` | Run `promtool check rules` on alert rules |
+| `fmt` | Auto-format all `.libsonnet` source files |
+
+`vendor/` is not checked in; `jsonnetfile.lock.json` pins the exact grafonnet 
revision, so
+`make install` reproduces the same dependency tree.
+
+**Commit the regenerated artifacts together with the jsonnet change that 
produced them** —
+nothing in the Gradle build regenerates or verifies them.
+
+---
+
+## Developer Convenience Stack
+
+The `dev/` directory contains a docker-compose stack that starts a local Solr 
+ Prometheus
++ Grafana + Alertmanager environment. **This is solely for contributors who 
want to
+visually test changes to the two artifacts above.** It is not a reference 
deployment
+or a recommended production setup, and is excluded from the source release.
+
+```bash
+cd dev
+./stack.sh                  # bundled Solr nodes + monitoring + traffic 
generator
+./stack.sh --local-solr     # monitoring only, scraping a Solr you already run 
on :8983
+./stack.sh --down           # stop and remove everything
+```
+
+Use `stack.sh` rather than `docker compose up` directly: the bundled Solr 
nodes sit behind the
+`bundled-solr` compose profile, so a plain `docker compose up` would start the 
monitoring services
+with no Solr to scrape.
+
+Services: Solr (`:8983`, `:8984`), Prometheus (`:9090`), Grafana (`:3000`), 
Alertmanager
+(`:9093`). Grafana opens straight on the provisioned dashboard with no login — 
anonymous viewing
+is enabled because this stack is local and throwaway; sign in as 
`admin`/`admin` to edit. The
+Prometheus datasource is provisioned too, and Prometheus loads the alert 
rules. Both artifacts are bind-mounted as single
+files, which the containers resolve once at start, so after regenerating one, 
recreate them:
+
+```bash
+docker compose up -d --force-recreate grafana prometheus
+```
+
+The monitoring images are pinned for reproducibility. Override `SOLR_IMAGE`, 
`PROMETHEUS_IMAGE`,
+`GRAFANA_IMAGE` or `ALERTMANAGER_IMAGE` to test against other versions:
+
+```bash
+GRAFANA_IMAGE=grafana/grafana:latest ./stack.sh
+```
+
+`trafficgen/` is a small Python container that indexes and queries 
continuously, so the panels
+show movement. It exists for this stack only.
diff --git a/solr/monitoring/dev/alertmanager/alertmanager.yml 
b/solr/monitoring/dev/alertmanager/alertmanager.yml
new file mode 100644
index 00000000000..dd475ba305f
--- /dev/null
+++ b/solr/monitoring/dev/alertmanager/alertmanager.yml
@@ -0,0 +1,86 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# alertmanager.yml — Example Alertmanager routing configuration for Solr 
alerts.
+#
+# Two-tier severity routing:
+#   severity: critical  → critical-receiver  (intended for: PagerDuty, 
OpsGenie, paging on-call)
+#   severity: warning   → warning-receiver   (intended for: Slack, email, 
non-urgent notification)
+#
+# Replace the stub "null" receivers with your real notification integrations.
+# See https://prometheus.io/docs/alerting/latest/configuration/ for receiver 
examples.
+
+global:
+  resolve_timeout: 5m
+
+route:
+  # Default receiver for any alert not matched by child routes.
+  receiver: "default"
+  group_by: ["alertname", "instance"]
+  group_wait: 30s
+  group_interval: 5m
+  repeat_interval: 4h
+
+  routes:
+    # Critical alerts — page the on-call engineer immediately.
+    # Solr critical alerts: SolrHighHeapUsage, SolrJvmGcThrashing, 
SolrLowDiskSpace
+    - matchers:
+        - severity = "critical"
+      receiver: "critical-receiver"
+      group_wait: 10s
+      repeat_interval: 1h
+
+    # Warning alerts — notify the team but do not page.
+    # Solr warning alerts: SolrHighSearchLatency, SolrHighErrorRate,
+    #                      SolrOverseerQueueBuildup, SolrHighMmapRatio
+    - matchers:
+        - severity = "warning"
+      receiver: "warning-receiver"
+      repeat_interval: 6h
+
+receivers:
+  # Default catch-all receiver (no-op stub).
+  - name: "default"
+
+  # Critical receiver — replace with PagerDuty, OpsGenie, VictorOps, or 
similar.
+  # PagerDuty example:
+  #   - name: "critical-receiver"
+  #     pagerduty_configs:
+  #       - service_key: YOUR_PAGERDUTY_INTEGRATION_KEY
+  - name: "critical-receiver"
+
+  # Warning receiver — replace with Slack, email, or similar.
+  # Slack example:
+  #   - name: "warning-receiver"
+  #     slack_configs:
+  #       - api_url: https://hooks.slack.com/services/YOUR/SLACK/WEBHOOK
+  #         channel: '#solr-alerts'
+  #         title: '{{ .GroupLabels.alertname }}'
+  #         text: '{{ range .Alerts }}{{ .Annotations.description }}{{ end }}'
+  # Email example:
+  #   - name: "warning-receiver"
+  #     email_configs:
+  #       - to: '[email protected]'
+  #         from: '[email protected]'
+  #         smarthost: 'smtp.example.com:587'
+  - name: "warning-receiver"
+
+inhibit_rules:
+  # Suppress warning alerts on an instance when a critical alert is already 
firing for it.
+  - source_matchers:
+      - severity = "critical"
+    target_matchers:
+      - severity = "warning"
+    equal: ["instance"]
diff --git a/solr/monitoring/dev/docker-compose.yml 
b/solr/monitoring/dev/docker-compose.yml
new file mode 100644
index 00000000000..4af98d4691b
--- /dev/null
+++ b/solr/monitoring/dev/docker-compose.yml
@@ -0,0 +1,155 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# ============================================================================
+# DEVELOPER CONVENIENCE ONLY — NOT FOR PRODUCTION USE
+# ============================================================================
+#
+# This docker-compose stack is provided solely to make it easy for contributors
+# to view and test changes to the two generated monitoring artifacts:
+#
+#   ../grafana-solr-dashboard.json    — Grafana dashboard
+#   ../prometheus-solr-alerts.yml     — Prometheus alert rules
+#
+# It is NOT intended as a reference deployment or a recommended production 
setup.
+# For integrating the artifacts into your own Prometheus + Grafana 
installation,
+# see the Solr Reference Guide (monitoring-with-prometheus-and-grafana.adoc).
+#
+# Quick start:
+#   cd solr/monitoring/dev
+#   ./stack.sh                     # Full stack with bundled Solr nodes
+#   ./stack.sh --local-solr        # Monitoring only — point at your 
locally-running Solr
+#   ./stack.sh --help              # All options
+#
+# Ports are published on loopback only: the stack runs Solr and ZooKeeper 
without
+# authentication, so it must not be reachable from other hosts on the network.
+#
+# Services (full stack):
+#   solr1        http://localhost:8983  (SolrCloud node 1, embedded ZooKeeper)
+#   solr2        http://localhost:8984  (SolrCloud node 2)
+#   Prometheus   http://localhost:9090
+#   Grafana      http://localhost:3000  (admin / admin)
+#   Alertmanager http://localhost:9093
+#   trafficgen   (no port — continuously indexes/searches the 'test' 
collection)
+#
+# Environment variables (set by stack.sh, or override manually):
+#   PROMETHEUS_CONFIG   Path to the prometheus config to mount (default: 
./prometheus/prometheus.yml)
+#   SOLR_URL            Solr base URL for trafficgen (default: 
http://solr1:8983/solr)
+#   SOLR_IMAGE          Solr image for the bundled nodes (default: solr:10)
+#   PROMETHEUS_IMAGE    Prometheus image (default: pinned, see below)
+#   GRAFANA_IMAGE       Grafana image (default: pinned, see below)
+#   ALERTMANAGER_IMAGE  Alertmanager image (default: pinned, see below)
+#
+# The monitoring images are pinned so a stack that worked yesterday still works
+# today; override any of them to test against a newer release.
+# ============================================================================
+
+services:
+  solr1:
+    image: ${SOLR_IMAGE:-solr:10}
+    profiles: [bundled-solr]
+    ports:
+      - "127.0.0.1:8983:8983"
+      - "127.0.0.1:9983:9983"   # embedded ZooKeeper (Solr 10 default: ZK_PORT 
= SOLR_PORT + 1000)
+    # No ZK_HOST set: Solr 10 defaults to SolrCloud with embedded ZooKeeper on 
port 9983.
+    healthcheck:
+      test: ["CMD-SHELL", "curl -sf 
http://localhost:8983/solr/admin/info/system || exit 1"]
+      interval: 10s
+      timeout: 5s
+      retries: 12
+      start_period: 40s
+
+  solr2:
+    image: ${SOLR_IMAGE:-solr:10}
+    profiles: [bundled-solr]
+    ports:
+      - "127.0.0.1:8984:8983"
+    environment:
+      ZK_HOST: "solr1:9983"
+    depends_on:
+      solr1:
+        condition: service_healthy
+    healthcheck:
+      test: ["CMD-SHELL", "curl -sf 
http://localhost:8983/solr/admin/info/system || exit 1"]
+      interval: 10s
+      timeout: 5s
+      retries: 12
+      start_period: 30s
+
+  prometheus:
+    image: ${PROMETHEUS_IMAGE:-prom/prometheus:v3.15.0}
+    ports:
+      - "127.0.0.1:9090:9090"
+    extra_hosts:
+      - "host.docker.internal:host-gateway"
+    volumes:
+      - 
${PROMETHEUS_CONFIG:-./prometheus/prometheus.yml}:/etc/prometheus/prometheus.yml:ro
+      - ../prometheus-solr-alerts.yml:/etc/prometheus/rules/solr-alerts.yml:ro
+    command:
+      - "--config.file=/etc/prometheus/prometheus.yml"
+      - "--storage.tsdb.retention.time=7d"
+      - "--web.enable-lifecycle"
+    depends_on:
+      solr1:
+        condition: service_healthy
+        required: false
+      solr2:
+        condition: service_healthy
+        required: false
+    restart: unless-stopped
+
+  grafana:
+    image: ${GRAFANA_IMAGE:-grafana/grafana:13.2.3}
+    ports:
+      - "127.0.0.1:3000:3000"
+    environment:
+      GF_SECURITY_ADMIN_USER: admin
+      GF_SECURITY_ADMIN_PASSWORD: admin
+      GF_USERS_ALLOW_SIGN_UP: "false"
+      # Local throwaway stack: let anyone view without logging in, so the 
dashboard
+      # is one click away. Log in as admin/admin to edit.
+      GF_AUTH_ANONYMOUS_ENABLED: "true"
+      GF_AUTH_ANONYMOUS_ORG_ROLE: Viewer
+      # Land on the provisioned dashboard instead of Grafana's welcome page.
+      GF_DASHBOARDS_DEFAULT_HOME_DASHBOARD_PATH: 
/var/lib/grafana/dashboards/grafana-solr-dashboard.json
+    volumes:
+      - ./grafana/provisioning:/etc/grafana/provisioning:ro
+      - 
../grafana-solr-dashboard.json:/var/lib/grafana/dashboards/grafana-solr-dashboard.json:ro
+    depends_on:
+      - prometheus
+    restart: unless-stopped
+
+  alertmanager:
+    image: ${ALERTMANAGER_IMAGE:-prom/alertmanager:v0.34.1}
+    ports:
+      - "127.0.0.1:9093:9093"
+    volumes:
+      - ./alertmanager/alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro
+    restart: unless-stopped
+
+  trafficgen:
+    build: ./trafficgen
+    extra_hosts:
+      - "host.docker.internal:host-gateway"
+    environment:
+      SOLR_URL: ${SOLR_URL:-http://solr1:8983/solr}
+    depends_on:
+      solr1:
+        condition: service_healthy
+        required: false
+      solr2:
+        condition: service_healthy
+        required: false
+    restart: unless-stopped
diff --git a/solr/monitoring/dev/grafana/provisioning/dashboards/dashboards.yml 
b/solr/monitoring/dev/grafana/provisioning/dashboards/dashboards.yml
new file mode 100644
index 00000000000..8fab25f256f
--- /dev/null
+++ b/solr/monitoring/dev/grafana/provisioning/dashboards/dashboards.yml
@@ -0,0 +1,28 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# Grafana dashboard provisioning — tells Grafana to load dashboards from the
+# /var/lib/grafana/dashboards directory (mounted by docker-compose.yml).
+
+apiVersion: 1
+
+providers:
+  - name: Solr
+    folder: Solr
+    type: file
+    disableDeletion: false
+    editable: false
+    options:
+      path: /var/lib/grafana/dashboards
diff --git 
a/solr/monitoring/dev/grafana/provisioning/datasources/prometheus.yml 
b/solr/monitoring/dev/grafana/provisioning/datasources/prometheus.yml
new file mode 100644
index 00000000000..1e877ed476e
--- /dev/null
+++ b/solr/monitoring/dev/grafana/provisioning/datasources/prometheus.yml
@@ -0,0 +1,29 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# Grafana datasource provisioning — automatically configures the Prometheus
+# datasource when Grafana starts (used by docker-compose.yml).
+
+apiVersion: 1
+
+datasources:
+  - name: Prometheus
+    type: prometheus
+    access: proxy
+    url: http://prometheus:9090
+    isDefault: true
+    editable: false
+    jsonData:
+      timeInterval: "15s"
diff --git a/solr/monitoring/dev/prometheus/prometheus-local.yml 
b/solr/monitoring/dev/prometheus/prometheus-local.yml
new file mode 100644
index 00000000000..b3935124c3f
--- /dev/null
+++ b/solr/monitoring/dev/prometheus/prometheus-local.yml
@@ -0,0 +1,48 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# prometheus-local.yml — scrape config for a Solr you run yourself.
+#
+# Used by: ./stack.sh --local-solr
+#
+# 'host.docker.internal' resolves to the host machine from inside Docker (Mac,
+# Windows, and Linux with Docker 20.10+). docker-compose.yml adds the matching
+# extra_hosts entry so this also works on Linux.
+#
+# A single node on :8983 is scraped by default. Add further ports to the 
targets
+# list to match a multi-node local SolrCloud setup.
+
+global:
+  scrape_interval: 15s
+  evaluation_interval: 15s
+
+rule_files:
+  - /etc/prometheus/rules/solr-alerts.yml
+
+# Hand firing alerts to the Alertmanager container in this stack; without this
+# the rules evaluate but nothing is ever delivered.
+alerting:
+  alertmanagers:
+    - static_configs:
+        - targets: ['alertmanager:9093']
+
+scrape_configs:
+  - job_name: solr
+    # The v1 endpoint, which every Solr 10.x build serves. Solr 10.1 and later
+    # also serve the shorter v2 alias /api/metrics.
+    metrics_path: /solr/admin/metrics
+    scrape_interval: 15s
+    static_configs:
+      - targets: ['host.docker.internal:8983']
diff --git a/solr/monitoring/dev/prometheus/prometheus.yml 
b/solr/monitoring/dev/prometheus/prometheus.yml
new file mode 100644
index 00000000000..821ce876be7
--- /dev/null
+++ b/solr/monitoring/dev/prometheus/prometheus.yml
@@ -0,0 +1,43 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# prometheus.yml — scrape config for the bundled Solr nodes in the dev stack.
+#
+# Used by docker-compose.yml in this directory. The job name must match the
+# mixin's solrSelector (job="solr" by default), or the generated dashboard and
+# alert rules will not match any series.
+
+global:
+  scrape_interval: 15s
+  evaluation_interval: 15s
+
+rule_files:
+  - /etc/prometheus/rules/solr-alerts.yml
+
+# Hand firing alerts to the Alertmanager container in this stack; without this
+# the rules evaluate but nothing is ever delivered.
+alerting:
+  alertmanagers:
+    - static_configs:
+        - targets: ['alertmanager:9093']
+
+scrape_configs:
+  - job_name: solr
+    # The v1 endpoint, which every Solr 10.x build serves. Solr 10.1 and later
+    # also serve the shorter v2 alias /api/metrics.
+    metrics_path: /solr/admin/metrics
+    scrape_interval: 15s
+    static_configs:
+      - targets: ['solr1:8983', 'solr2:8983']
diff --git a/solr/monitoring/dev/stack.sh b/solr/monitoring/dev/stack.sh
new file mode 100755
index 00000000000..3d6129faef0
--- /dev/null
+++ b/solr/monitoring/dev/stack.sh
@@ -0,0 +1,130 @@
+#!/usr/bin/env bash
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# stack.sh — convenience launcher for the Solr monitoring dev stack.
+#
+# Usage:
+#   ./stack.sh                            Full stack: bundled Solr + 
monitoring + trafficgen
+#   ./stack.sh --local-solr               Monitoring only; Prometheus scrapes 
your local Solr
+#                                         at localhost:8983 (no bundled Solr 
started)
+#   ./stack.sh --local-solr URL           Same, but trafficgen is also pointed 
at URL
+#                                         (implies --trafficgen)
+#   ./stack.sh --local-solr --trafficgen  Local mode + trafficgen at default 
localhost:8983
+#   ./stack.sh --down                     Stop and remove all containers
+#   ./stack.sh --help                     Show this help
+#
+# In --local-solr mode Prometheus uses prometheus/prometheus-local.yml which 
scrapes
+# host.docker.internal:8983.  Edit that file to add further nodes or change 
the port.
+
+set -euo pipefail
+
+cd "$(dirname "$0")"
+
+LOCAL_MODE=false
+WITH_TRAFFICGEN=false
+CUSTOM_SOLR_URL=""
+DOWN_MODE=false
+
+usage() {
+  awk '/^# Usage:/,/^[^#]/' "$0" | grep '^#' | sed 's/^# \{0,1\}//'
+  exit 0
+}
+
+# Translate long options to short equivalents so getopts can process them 
uniformly.
+_args=()
+for _a in "$@"; do
+  case "$_a" in
+    --local-solr) _args+=(-l) ;;
+    --trafficgen) _args+=(-t) ;;
+    --down)       _args+=(-d) ;;
+    --help)       _args+=(-h) ;;
+    *)            _args+=("$_a") ;;
+  esac
+done
+set -- "${_args[@]+"${_args[@]}"}"
+
+while getopts "ltdh" opt; do
+  case "$opt" in
+    l) LOCAL_MODE=true ;;
+    t) WITH_TRAFFICGEN=true ;;
+    d) DOWN_MODE=true ;;
+    h) usage ;;
+    ?) echo "Unknown option. Run ./stack.sh --help for usage." >&2; exit 1 ;;
+  esac
+done
+shift $((OPTIND - 1))
+
+# Optional URL positional argument following -l / --local-solr
+if $LOCAL_MODE && [[ $# -gt 0 ]]; then
+  CUSTOM_SOLR_URL="$1"
+  WITH_TRAFFICGEN=true
+  shift
+fi
+
+if $DOWN_MODE; then
+  # Include the bundled-solr profile so solr1/solr2 are stopped even if they 
were started
+  # with that profile active.  Passing --profile to a non-running service is 
harmless.
+  docker compose --profile bundled-solr down
+  echo "All containers stopped and removed."
+  exit 0
+fi
+
+if $LOCAL_MODE; then
+  export PROMETHEUS_CONFIG="./prometheus/prometheus-local.yml"
+
+  if [[ -n "$CUSTOM_SOLR_URL" ]]; then
+    # Replace 'localhost' with host.docker.internal so trafficgen can reach 
the host
+    export SOLR_URL="${CUSTOM_SOLR_URL/localhost/host.docker.internal}"
+  else
+    export SOLR_URL="http://host.docker.internal:8983/solr";
+  fi
+
+  SERVICES="prometheus grafana alertmanager"
+  if $WITH_TRAFFICGEN; then
+    SERVICES="$SERVICES trafficgen"
+  fi
+
+  # shellcheck disable=SC2086
+  docker compose up -d $SERVICES
+
+  echo ""
+  echo "Monitoring stack started in local-Solr mode."
+  echo "Prometheus is scraping your local Solr via host.docker.internal"
+  if $WITH_TRAFFICGEN; then
+    echo "Traffic generator is running against: $SOLR_URL"
+  fi
+  echo ""
+  echo "  Dashboard:  http://localhost:3000/d/solr-mixin-example";
+  echo "  Grafana:    http://localhost:3000  (view without login; admin/admin 
to edit)"
+  echo "  Prometheus: http://localhost:9090";
+  echo "  Alertmanager: http://localhost:9093";
+  echo ""
+  echo "Tip: edit prometheus/prometheus-local.yml to add further nodes or 
change the port."
+else
+  # Bundled mode: activate the bundled-solr profile so solr1 and solr2 start
+  COMPOSE_PROFILES=bundled-solr docker compose up -d
+
+  echo ""
+  echo "Full monitoring stack started."
+  echo "  Solr 1:       http://localhost:8983";
+  echo "  Solr 2:       http://localhost:8984";
+  echo "  Dashboard:    http://localhost:3000/d/solr-mixin-example";
+  echo "  Grafana:      http://localhost:3000  (view without login; 
admin/admin to edit)"
+  echo "  Prometheus:   http://localhost:9090";
+  echo "  Alertmanager: http://localhost:9093";
+  echo ""
+  echo "Tip: Prometheus may show scrape errors for the first 30-60 seconds 
while Solr starts."
+fi
diff --git a/solr/monitoring/dev/trafficgen/Dockerfile 
b/solr/monitoring/dev/trafficgen/Dockerfile
new file mode 100644
index 00000000000..5fa4c237170
--- /dev/null
+++ b/solr/monitoring/dev/trafficgen/Dockerfile
@@ -0,0 +1,29 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+FROM python:3.12-slim
+
+WORKDIR /app
+
+COPY requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
+
+COPY trafficgen.py .
+COPY trafficgen.yml .
+
+RUN useradd -r -u 1000 -g root trafficgen
+USER 1000
+
+ENTRYPOINT ["python3", "trafficgen.py"]
diff --git a/solr/monitoring/dev/trafficgen/requirements.txt 
b/solr/monitoring/dev/trafficgen/requirements.txt
new file mode 100644
index 00000000000..f84ec71246f
--- /dev/null
+++ b/solr/monitoring/dev/trafficgen/requirements.txt
@@ -0,0 +1,3 @@
+pysolr>=3.10.0
+requests>=2.31.0
+PyYAML>=6.0
diff --git a/solr/monitoring/dev/trafficgen/trafficgen.py 
b/solr/monitoring/dev/trafficgen/trafficgen.py
new file mode 100644
index 00000000000..292092a789d
--- /dev/null
+++ b/solr/monitoring/dev/trafficgen/trafficgen.py
@@ -0,0 +1,298 @@
+#!/usr/bin/env python3
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""trafficgen.py - Traffic generator for Apache Solr
+
+Generates continuous index and search traffic against a Solr endpoint.
+Configuration is read from a YAML file (default: trafficgen.yml in same 
directory).
+If the config has no 'words' key, a vocabulary is generated in RAM at startup.
+
+Environment variable overrides:
+  STOP_AFTER=<secs>           Stop after this many seconds (default: run 
forever)
+  CONFIG_FILE=<path>          Path to config file (default: trafficgen.yml)
+  INDEX_SLEEP_SECS=<secs>     Override indexing.sleepSecs
+  SEARCH_SLEEP_SECS=<secs>    Override searching.sleepSecs
+  DELETIONS_SLEEP_SECS=<secs> Override deletions.sleepSecs
+  QUIET=true                  Suppress per-operation log lines
+  SOLR_URL=<url>              Override solrUrl
+"""
+
+import argparse
+import logging
+import os
+import random
+import sys
+import threading
+import time
+import uuid
+
+import pysolr
+import requests
+import yaml
+
+
+def generate_words(count=300):
+    """Generate a pronounceable in-RAM vocabulary of `count` unique words."""
+    consonants = "bcdfghjklmnprstvwz"
+    vowels = "aeiou"
+    words = set()
+    while len(words) < count:
+        length = random.randint(4, 9)
+        word = ""
+        for i in range(length):
+            word += random.choice(consonants if i % 2 == 0 else vowels)
+        words.add(word)
+    return sorted(words)
+
+
+def load_config(config_file):
+    with open(config_file, "r") as f:
+        return yaml.safe_load(f)
+
+
+def apply_env_overrides(config):
+    if os.environ.get("SOLR_URL"):
+        config["solrUrl"] = os.environ["SOLR_URL"]
+    if os.environ.get("INDEX_SLEEP_SECS"):
+        config["indexing"]["sleepSecs"] = float(os.environ["INDEX_SLEEP_SECS"])
+    # Support both correct and typo variant from spec
+    search_secs = os.environ.get("SEARCH_SLEEP_SECS") or 
os.environ.get("SEARCH_SEEP_SECS")
+    if search_secs:
+        config["searching"]["sleepSecs"] = float(search_secs)
+    if os.environ.get("DELETIONS_SLEEP_SECS"):
+        config["deletions"]["sleepSecs"] = 
float(os.environ["DELETIONS_SLEEP_SECS"])
+    return config
+
+
+def ensure_collection_exists(solr_url, collection_config):
+    name = collection_config["name"]
+    num_shards = collection_config.get("numShards", 1)
+    replication_factor = collection_config.get("replicationFactor", 1)
+    config_set = collection_config.get("configSet", "_default")
+
+    admin_url = f"{solr_url.rstrip('/')}/admin/collections"
+
+    try:
+        resp = requests.get(admin_url, params={"action": "LIST", "wt": 
"json"}, timeout=10)
+        resp.raise_for_status()
+        collections = resp.json().get("collections", [])
+    except Exception as e:
+        logging.warning(f"CREATE: Could not list collections (non-cloud 
Solr?): {e}")
+        return
+
+    if name in collections:
+        logging.info(f"CREATE: Collection '{name}' already exists")
+        return
+
+    logging.info(
+        f"CREATE: Creating collection '{name}' with numShards={num_shards}, "
+        f"replicationFactor={replication_factor}, configSet='{config_set}'"
+    )
+    try:
+        create_resp = requests.get(
+            admin_url,
+            params={
+                "action": "CREATE",
+                "name": name,
+                "numShards": num_shards,
+                "replicationFactor": replication_factor,
+                "collection.configName": config_set,
+                "wt": "json",
+            },
+            timeout=30,
+        )
+        create_resp.raise_for_status()
+        result = create_resp.json()
+        status = result.get("responseHeader", {}).get("status", -1)
+        if status != 0:
+            raise RuntimeError(f"Unexpected response status {status}: 
{result}")
+        logging.info(f"CREATE: Collection '{name}' created successfully")
+    except Exception as e:
+        logging.error(f"CREATE: Failed to create collection '{name}': {e}")
+        sys.exit(1)
+
+
+def build_document(words, freetext_field, facet_field, num_words):
+    return {
+        "id": str(uuid.uuid4()),
+        freetext_field: " ".join(random.choices(words, k=num_words)),
+        facet_field: random.choice(words),
+    }
+
+
+def run_indexing(solr, config, words, stop_event, quiet):
+    batch_size = config["indexing"]["batchSize"]
+    sleep_secs = config["indexing"]["sleepSecs"]
+    freetext_field = config["indexing"]["freetextFieldName"]
+    facet_field = config["indexing"]["facetFieldName"]
+    num_words = config["indexing"]["numWords"]
+
+    while not stop_event.is_set():
+        docs = [build_document(words, freetext_field, facet_field, num_words) 
for _ in range(batch_size)]
+        try:
+            t0 = time.monotonic()
+            solr.add(docs)
+            ms = int((time.monotonic() - t0) * 1000)
+            if not quiet:
+                logging.info(f"INDEX: batch={batch_size} 
field={freetext_field} time={ms}ms")
+        except Exception as e:
+            logging.error(f"INDEX: {e}")
+        stop_event.wait(sleep_secs)
+
+
+def run_deletions(solr, config, words, stop_event, quiet):
+    sleep_secs = config["deletions"]["sleepSecs"]
+    freetext_field = config["indexing"]["freetextFieldName"]
+
+    while not stop_event.is_set():
+        word = random.choice(words)
+        q = f"{freetext_field}:{word}"
+        try:
+            t0 = time.monotonic()
+            solr.delete(q=q)
+            ms = int((time.monotonic() - t0) * 1000)
+            if not quiet:
+                logging.info(f"DELETE: q='{q}' time={ms}ms")
+        except Exception as e:
+            logging.error(f"DELETE: {e}")
+        stop_event.wait(sleep_secs)
+
+
+def run_searching(solr, config, words, stop_event, quiet):
+    sleep_secs = config["searching"]["sleepSecs"]
+    freetext_field = config["searching"]["freetextFieldName"]
+    facet_field = config["searching"]["facetFieldName"]
+    num_words = config["searching"]["numWords"]
+
+    while not stop_event.is_set():
+        query_words = random.choices(words, k=num_words)
+        if num_words == 1:
+            q = f"{freetext_field}:{query_words[0]}"
+        else:
+            q = f"{freetext_field}:({' '.join(query_words)})"
+        try:
+            t0 = time.monotonic()
+            results = solr.search(
+                q,
+                rows=10,
+                facet="true",
+                **{"facet.field": facet_field},
+            )
+            ms = int((time.monotonic() - t0) * 1000)
+            if not quiet:
+                logging.info(f"SEARCH: q='{q}' hits={results.hits} 
time={ms}ms")
+        except Exception as e:
+            logging.error(f"SEARCH: {e}")
+        stop_event.wait(sleep_secs)
+
+
+def main():
+    parser = argparse.ArgumentParser(
+        description="Traffic generator for Apache Solr - continuously indexes 
and searches documents.",
+        formatter_class=argparse.RawDescriptionHelpFormatter,
+        epilog=__doc__,
+    )
+    parser.parse_args()
+
+    logging.basicConfig(
+        level=logging.INFO,
+        format="%(asctime)s %(levelname)s %(message)s",
+        datefmt="%Y-%m-%dT%H:%M:%S",
+        stream=sys.stdout,
+    )
+    logging.getLogger("pysolr").setLevel(logging.WARNING)
+
+    config_file = os.environ.get("CONFIG_FILE")
+    if not config_file:
+        script_dir = os.path.dirname(os.path.abspath(__file__))
+        config_file = os.path.join(script_dir, "trafficgen.yml")
+
+    logging.info(f"Loading config from {config_file}")
+    config = load_config(config_file)
+    config = apply_env_overrides(config)
+
+    if config.get("words"):
+        words = config["words"]
+        logging.info(f"Vocabulary: {len(words)} words loaded from config")
+    else:
+        words = generate_words(300)
+        logging.info(f"Vocabulary: {len(words)} words auto-generated in RAM")
+
+    quiet = os.environ.get("QUIET", "").lower() == "true"
+    stop_after_secs = float(os.environ["STOP_AFTER"]) if 
os.environ.get("STOP_AFTER") else None
+
+    solr_url = config["solrUrl"].rstrip("/")
+    collection_name = config["collection"]["name"]
+
+    logging.info(f"Solr URL: {solr_url}")
+    logging.info(f"Collection: {collection_name}")
+
+    ensure_collection_exists(solr_url, config["collection"])
+
+    solr = pysolr.Solr(
+        f"{solr_url}/{collection_name}",
+        always_commit=True,
+        timeout=10,
+    )
+
+    stop_event = threading.Event()
+
+    index_thread = threading.Thread(
+        target=run_indexing,
+        args=(solr, config, words, stop_event, quiet),
+        daemon=True,
+        name="indexer",
+    )
+    search_thread = threading.Thread(
+        target=run_searching,
+        args=(solr, config, words, stop_event, quiet),
+        daemon=True,
+        name="searcher",
+    )
+    deletion_thread = threading.Thread(
+        target=run_deletions,
+        args=(solr, config, words, stop_event, quiet),
+        daemon=True,
+        name="deletions",
+    )
+
+    index_thread.start()
+    search_thread.start()
+    deletion_thread.start()
+
+    if stop_after_secs:
+        logging.info(f"Traffic generation started, will stop after 
{stop_after_secs}s")
+    else:
+        logging.info("Traffic generation started, running forever (set 
STOP_AFTER to limit duration)")
+
+    try:
+        if stop_after_secs:
+            time.sleep(stop_after_secs)
+            logging.info("STOP_AFTER reached, shutting down")
+        else:
+            while True:
+                time.sleep(1)
+    except KeyboardInterrupt:
+        logging.info("Interrupted, shutting down")
+    finally:
+        stop_event.set()
+        index_thread.join(timeout=10)
+        search_thread.join(timeout=10)
+        deletion_thread.join(timeout=10)
+        logging.info("Traffic generation stopped")
+
+
+if __name__ == "__main__":
+    main()
diff --git a/solr/monitoring/dev/trafficgen/trafficgen.yml 
b/solr/monitoring/dev/trafficgen/trafficgen.yml
new file mode 100644
index 00000000000..58610b827c4
--- /dev/null
+++ b/solr/monitoring/dev/trafficgen/trafficgen.yml
@@ -0,0 +1,45 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# trafficgen.yml — configuration for the dev docker-compose stack.
+#
+# Tuned for SolrCloud (numShards: 2) with faster indexing so the
+# Grafana dashboard panels show activity immediately.
+# Solr URL is overridden at runtime via SOLR_URL env var in docker-compose.yml.
+# Word list is auto-generated in RAM by trafficgen.py (no 'words' key needed).
+
+solrUrl: http://localhost:8983/solr
+
+collection:
+  name: test
+  numShards: 2
+  replicationFactor: 2
+  configSet: _default
+
+indexing:
+  batchSize: 10
+  sleepSecs: 5
+  freetextFieldName: text_t
+  numWords: 20
+  facetFieldName: keywords_s
+
+searching:
+  sleepSecs: 1
+  freetextFieldName: text_t
+  numWords: 1
+  facetFieldName: keywords_s
+
+deletions:
+  sleepSecs: 30
diff --git a/solr/monitoring/grafana-solr-dashboard.json 
b/solr/monitoring/grafana-solr-dashboard.json
new file mode 100644
index 00000000000..e35648dc22e
--- /dev/null
+++ b/solr/monitoring/grafana-solr-dashboard.json
@@ -0,0 +1,149 @@
+{
+   "__license": "Licensed to the Apache Software Foundation (ASF) under one or 
more contributor license agreements. See the NOTICE file distributed with this 
work for additional information regarding copyright ownership. The ASF licenses 
this file to You under the Apache License, Version 2.0 (the \"License\"); you 
may not use this file except in compliance with the License. You may obtain a 
copy of the License at http://www.apache.org/licenses/LICENSE-2.0 — Generated 
by mixin/dashboards/ [...]
+   "description": "Placeholder dashboard that exercises the monitoring mixin 
build. It is not the Solr dashboard — that is still being designed, see 
SOLR-18534.",
+   "editable": true,
+   "panels": [
+      {
+         "datasource": {
+            "type": "datasource",
+            "uid": "-- Mixed --"
+         },
+         "description": "Requests per second per node, across all handlers and 
cores.",
+         "fieldConfig": {
+            "defaults": {
+               "unit": "reqps"
+            }
+         },
+         "gridPos": {
+            "h": 8,
+            "w": 12,
+            "x": 0,
+            "y": 0
+         },
+         "id": 1,
+         "options": {
+            "legend": {
+               "displayMode": "list"
+            },
+            "tooltip": {
+               "mode": "multi"
+            }
+         },
+         "pluginVersion": "v11.4.0",
+         "targets": [
+            {
+               "datasource": {
+                  "type": "prometheus",
+                  "uid": "$datasource"
+               },
+               "expr": "sum by 
(instance)(rate(solr_core_requests_total{job=\"solr\"}[$interval]))",
+               "legendFormat": "{{instance}}"
+            }
+         ],
+         "title": "Request Rate",
+         "type": "timeseries"
+      },
+      {
+         "datasource": {
+            "type": "datasource",
+            "uid": "-- Mixed --"
+         },
+         "description": "JVM heap in use per node, summed over the heap memory 
pools. The inner max collapses pools reported by more than one OTel 
instrumentation scope, which differ by Solr version.",
+         "fieldConfig": {
+            "defaults": {
+               "unit": "bytes"
+            }
+         },
+         "gridPos": {
+            "h": 8,
+            "w": 12,
+            "x": 12,
+            "y": 0
+         },
+         "id": 2,
+         "options": {
+            "legend": {
+               "displayMode": "list"
+            },
+            "tooltip": {
+               "mode": "multi"
+            }
+         },
+         "pluginVersion": "v11.4.0",
+         "targets": [
+            {
+               "datasource": {
+                  "type": "prometheus",
+                  "uid": "$datasource"
+               },
+               "expr": "sum by (instance)(max by (instance, 
jvm_memory_pool_name)(jvm_memory_used_bytes{job=\"solr\",jvm_memory_type=\"heap\"}))",
+               "legendFormat": "{{instance}}"
+            }
+         ],
+         "title": "Heap Used",
+         "type": "timeseries"
+      }
+   ],
+   "refresh": "auto",
+   "schemaVersion": 36,
+   "tags": [
+      "solr",
+      "prometheus",
+      "example"
+   ],
+   "templating": {
+      "list": [
+         {
+            "label": "Data Source",
+            "name": "datasource",
+            "query": "prometheus",
+            "type": "datasource"
+         },
+         {
+            "current": {
+               "selected": false,
+               "text": "5m",
+               "value": "5m"
+            },
+            "label": "Interval",
+            "name": "interval",
+            "options": [
+               {
+                  "selected": true,
+                  "text": "1m",
+                  "value": "1m"
+               },
+               {
+                  "selected": false,
+                  "text": "5m",
+                  "value": "5m"
+               },
+               {
+                  "selected": false,
+                  "text": "10m",
+                  "value": "10m"
+               },
+               {
+                  "selected": false,
+                  "text": "30m",
+                  "value": "30m"
+               },
+               {
+                  "selected": false,
+                  "text": "1h",
+                  "value": "1h"
+               }
+            ],
+            "query": "1m,5m,10m,30m,1h",
+            "type": "interval"
+         }
+      ]
+   },
+   "time": {
+      "from": "now-15m",
+      "to": "now"
+   },
+   "timezone": "browser",
+   "title": "Solr Mixin Example",
+   "uid": "solr-mixin-example"
+}
diff --git a/solr/monitoring/mixin/.gitignore b/solr/monitoring/mixin/.gitignore
new file mode 100644
index 00000000000..8cd29773f01
--- /dev/null
+++ b/solr/monitoring/mixin/.gitignore
@@ -0,0 +1,2 @@
+vendor/
+out/
diff --git a/solr/monitoring/mixin/Dockerfile b/solr/monitoring/mixin/Dockerfile
new file mode 100644
index 00000000000..6f01dd0a7be
--- /dev/null
+++ b/solr/monitoring/mixin/Dockerfile
@@ -0,0 +1,55 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# ============================================================================
+# Build image for the Solr monitoring mixin.
+# Contains: jsonnet, jsonnetfmt, jb (jsonnet-bundler), gojsontoyaml, promtool.
+#
+# Not intended for production use — developer tooling only.
+# See make.sh for usage.
+# ============================================================================
+
+# --- Stage 1: compile Go-based tools -----------------------------------------
+FROM golang:1-bookworm AS go-tools
+
+ENV CGO_ENABLED=0
+
+RUN go install github.com/google/go-jsonnet/cmd/[email protected]    \
+ && go install github.com/google/go-jsonnet/cmd/[email protected] \
+ && go install github.com/jsonnet-bundler/jsonnet-bundler/cmd/[email protected] \
+ && go install github.com/brancz/[email protected]
+
+# --- Stage 2: extract promtool from the official Prometheus image 
-------------
+FROM prom/prometheus:v2.52.0 AS prometheus
+
+# --- Stage 3: lean runtime image ---------------------------------------------
+FROM ubuntu:24.04
+
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends make ca-certificates \
+ && rm -rf /var/lib/apt/lists/*
+
+COPY --from=go-tools   /go/bin/jsonnet      /usr/local/bin/
+COPY --from=go-tools   /go/bin/jsonnetfmt   /usr/local/bin/
+COPY --from=go-tools   /go/bin/jb           /usr/local/bin/
+COPY --from=go-tools   /go/bin/gojsontoyaml /usr/local/bin/
+COPY --from=prometheus /bin/promtool        /usr/local/bin/
+
+# The mixin source tree is expected to be mounted at /work/mixin
+# (i.e., the monitoring/ directory is mounted as /work).
+WORKDIR /work/mixin
+
+ENTRYPOINT ["make"]
+CMD ["all"]
diff --git a/solr/monitoring/mixin/Makefile b/solr/monitoring/mixin/Makefile
new file mode 100644
index 00000000000..a764b38eaa7
--- /dev/null
+++ b/solr/monitoring/mixin/Makefile
@@ -0,0 +1,86 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+JSONNET      = jsonnet
+JSONNETFMT   = jsonnetfmt
+JB           = jb
+GOJSONTOYAML = gojsontoyaml
+PROMTOOL     = promtool
+
+JSONNET_FLAGS = -J vendor
+FMT_FLAGS     = -n 2 --max-blank-lines 2 --string-style s --comment-style s
+
+DASHBOARD_OUT = ../grafana-solr-dashboard.json
+ALERTS_OUT    = ../prometheus-solr-alerts.yml
+
+# Generated artifacts are written to a temp file and moved into place only on
+# success, so a missing tool or a jsonnet error can never leave a committed
+# artifact truncated.  pipefail makes a failure anywhere in a pipe fatal.
+SHELL      := /bin/bash
+.SHELLFLAGS := -eu -o pipefail -c
+
+# Abort before touching any output if a required tool is missing.
+require_tool = @command -v $(1) >/dev/null 2>&1 || { \
+         echo "ERROR: required tool '$(1)' not found in PATH." >&2; \
+         echo "       Install the local toolchain (see ../README.md, 'Mixin 
Build')," >&2; \
+         echo "       or run ./make.sh $@ to build inside Docker instead." 
>&2; \
+         exit 1; }
+
+require_vendor = @test -d vendor || { \
+         echo "ERROR: vendor/ is missing — run 'make install' (or ./make.sh 
install) first." >&2; \
+         exit 1; }
+
+.PHONY: all dashboards alerts install check fmt clean
+
+all: dashboards alerts
+
+install:
+       $(call require_tool,$(JB))
+       $(JB) install
+       @echo "→ Installed jsonnet dependencies into vendor/"
+
+dashboards:
+       $(call require_tool,$(JSONNET))
+       $(require_vendor)
+       $(JSONNET) $(JSONNET_FLAGS) \
+         -e '(import 
"mixin.libsonnet").grafanaDashboards["grafana-solr-dashboard.json"]' \
+         > $(DASHBOARD_OUT).tmp || { rm -f $(DASHBOARD_OUT).tmp; exit 1; }
+       mv $(DASHBOARD_OUT).tmp $(DASHBOARD_OUT)
+       @echo "→ Generated $(DASHBOARD_OUT)"
+
+alerts:
+       $(call require_tool,$(JSONNET))
+       $(call require_tool,$(GOJSONTOYAML))
+       $(require_vendor)
+       cat license-header.yml > $(ALERTS_OUT).tmp
+       $(JSONNET) $(JSONNET_FLAGS) \
+         -e '(import "mixin.libsonnet").prometheusAlerts' \
+         | $(GOJSONTOYAML) >> $(ALERTS_OUT).tmp || { rm -f $(ALERTS_OUT).tmp; 
exit 1; }
+       mv $(ALERTS_OUT).tmp $(ALERTS_OUT)
+       @echo "→ Generated $(ALERTS_OUT)"
+
+check:
+       $(call require_tool,$(PROMTOOL))
+       $(PROMTOOL) check rules $(ALERTS_OUT)
+       @echo "→ Alert rules validated OK"
+
+fmt:
+       $(call require_tool,$(JSONNETFMT))
+       find . -path ./vendor -prune -o -name '*.libsonnet' -exec $(JSONNETFMT) 
$(FMT_FLAGS) -i {} \;
+       @echo "→ Formatted all .libsonnet files (vendor/ excluded)"
+
+clean:
+       rm -f $(DASHBOARD_OUT) $(ALERTS_OUT) $(DASHBOARD_OUT).tmp 
$(ALERTS_OUT).tmp
+       @echo "→ Removed generated artifacts"
diff --git a/solr/monitoring/mixin/alerts/alerts.libsonnet 
b/solr/monitoring/mixin/alerts/alerts.libsonnet
new file mode 100644
index 00000000000..93dad7f5fa2
--- /dev/null
+++ b/solr/monitoring/mixin/alerts/alerts.libsonnet
@@ -0,0 +1,51 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements.  See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// alerts.libsonnet — placeholder alert rule.
+//
+// One rule, enough to show that the mixin build produces a file promtool
+// accepts and Prometheus loads. The real rule set replaces this wholesale;
+// see SOLR-18535.
+//
+// The rules are built from the configuration of the merged mixin root
+// ($._config), not from a direct import, so that callers can override settings
+// without editing this tree.
+
+local alertsFor(cfg) = {
+  groups: [
+    {
+      name: 'SolrExampleAlerts',
+      rules: [
+        {
+          alert: 'SolrNodeDown',
+          expr: 'up{%s} == 0' % cfg.solrSelector,
+          'for': '2m',
+          labels: { severity: 'warning' },
+          annotations: {
+            summary: 'Solr node {{ $labels.instance }} is not responding to 
scrapes',
+            description: |||
+              Prometheus has failed to scrape {{ $labels.instance }} for 2 
minutes.
+              Either the node is down or its metrics endpoint is unreachable.
+            |||,
+          },
+        },
+      ],
+    },
+  ],
+};
+
+{
+  prometheusAlerts+:: alertsFor($._config),
+}
diff --git a/solr/monitoring/mixin/config.libsonnet 
b/solr/monitoring/mixin/config.libsonnet
new file mode 100644
index 00000000000..342948d14dd
--- /dev/null
+++ b/solr/monitoring/mixin/config.libsonnet
@@ -0,0 +1,35 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements.  See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// config.libsonnet — settings you can override without editing the dashboard
+// or alert sources.
+//
+// Every key here is consumed by dashboards/ or alerts/; nothing is declared
+// ahead of its use. To override, import the mixin and patch _config:
+//
+//   (import 'mixin.libsonnet') + { _config+:: { solrSelector: 'job="my-solr"' 
} }
+
+{
+  _config:: {
+    // Label selector that identifies Solr targets in your Prometheus. Applied 
to
+    // every dashboard query and alert expression, so JVM metrics scraped from
+    // other Java services are not mistaken for Solr's.
+    solrSelector: 'job="solr"',
+
+    // Window for rate() queries, and the preselected value of the dashboard's
+    // Interval dropdown. Raise it for sparse scrape intervals.
+    rateInterval: '5m',
+  },
+}
diff --git a/solr/monitoring/mixin/dashboards/dashboards.libsonnet 
b/solr/monitoring/mixin/dashboards/dashboards.libsonnet
new file mode 100644
index 00000000000..d214c72dbd7
--- /dev/null
+++ b/solr/monitoring/mixin/dashboards/dashboards.libsonnet
@@ -0,0 +1,102 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements.  See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// dashboards.libsonnet — placeholder dashboard.
+//
+// Two panels, enough to show that the mixin build produces a dashboard Grafana
+// can load and that config.libsonnet overrides reach the generated queries.
+// The real dashboard replaces this wholesale; see SOLR-18534.
+//
+// The dashboard is built from the configuration of the merged mixin root
+// ($._config), not from a direct import, so that callers can override settings
+// without editing this tree.
+
+local g = import 
'github.com/grafana/grafonnet/gen/grafonnet-latest/main.libsonnet';
+
+local d = g.dashboard;
+local p = g.panel;
+local q = g.query.prometheus;
+local v = g.dashboard.variable;
+
+local licenseComment =
+  'Licensed to the Apache Software Foundation (ASF) under one or more 
contributor license agreements. '
+  + 'See the NOTICE file distributed with this work for additional information 
regarding copyright ownership. '
+  + 'The ASF licenses this file to You under the Apache License, Version 2.0 
(the "License"); '
+  + 'you may not use this file except in compliance with the License. '
+  + 'You may obtain a copy of the License at 
http://www.apache.org/licenses/LICENSE-2.0 '
+  + '— Generated by mixin/dashboards/dashboards.libsonnet. To regenerate: cd 
mixin && make dashboards';
+
+local prom(expr, legend='{{instance}}') =
+  q.new('$datasource', expr)
+  + q.withLegendFormat(legend);
+
+local ts(title, exprs, unit='short', desc='') =
+  p.timeSeries.new(title)
+  + p.timeSeries.queryOptions.withTargets(exprs)
+  + p.timeSeries.standardOptions.withUnit(unit)
+  + p.timeSeries.panelOptions.withDescription(desc)
+  + p.timeSeries.options.legend.withDisplayMode('list')
+  + p.timeSeries.options.tooltip.withMode('multi');
+
+local dashboardFor(cfg) =
+  local datasourceVar =
+    v.datasource.new('datasource', 'prometheus')
+    + v.datasource.generalOptions.withLabel('Data Source');
+
+  local intervalVar =
+    v.interval.new('interval', ['1m', '5m', '10m', '30m', '1h'])
+    + v.interval.generalOptions.withCurrent(cfg.rateInterval)
+    + v.interval.generalOptions.withLabel('Interval');
+
+  local panels = [
+    ts(
+      'Request Rate',
+      [prom('sum by (instance)(rate(solr_core_requests_total{%s}[$interval]))' 
% cfg.solrSelector)],
+      unit='reqps',
+      desc='Requests per second per node, across all handlers and cores.'
+    ) + { gridPos: { x: 0, y: 0, w: 12, h: 8 } },
+
+    ts(
+      'Heap Used',
+      [prom(
+        'sum by (instance)(max by (instance, 
jvm_memory_pool_name)(jvm_memory_used_bytes{%s,jvm_memory_type="heap"}))' % 
cfg.solrSelector
+      )],
+      unit='bytes',
+      desc='JVM heap in use per node, summed over the heap memory pools. The 
inner max collapses pools reported by more than one OTel instrumentation scope, 
which differ by Solr version.'
+    ) + { gridPos: { x: 12, y: 0, w: 12, h: 8 } },
+  ];
+
+  { __license: licenseComment }
+  + d.new('Solr Mixin Example')
+  + d.withUid('solr-mixin-example')
+  + d.withDescription(
+    'Placeholder dashboard that exercises the monitoring mixin build. '
+    + 'It is not the Solr dashboard — that is still being designed, see 
SOLR-18534.'
+  )
+  + d.withTags(['solr', 'prometheus', 'example'])
+  + d.withRefresh('auto')
+  + d.withTimezone('browser')
+  + d.withEditable(true)
+  + d.withSchemaVersion(36)
+  + d.time.withFrom('now-15m')
+  + d.time.withTo('now')
+  + d.withVariables([datasourceVar, intervalVar])
+  + d.withPanels(panels);
+
+{
+  grafanaDashboards+:: {
+    'grafana-solr-dashboard.json': dashboardFor($._config),
+  },
+}
diff --git a/solr/monitoring/mixin/jsonnetfile.json 
b/solr/monitoring/mixin/jsonnetfile.json
new file mode 100644
index 00000000000..2d56d912452
--- /dev/null
+++ b/solr/monitoring/mixin/jsonnetfile.json
@@ -0,0 +1,15 @@
+{
+  "version": 1,
+  "dependencies": [
+    {
+      "source": {
+        "git": {
+          "remote": "https://github.com/grafana/grafonnet.git";,
+          "subdir": "gen/grafonnet-latest"
+        }
+      },
+      "version": "main"
+    }
+  ],
+  "legacyImports": false
+}
diff --git a/solr/monitoring/mixin/jsonnetfile.lock.json 
b/solr/monitoring/mixin/jsonnetfile.lock.json
new file mode 100644
index 00000000000..190410bcefd
--- /dev/null
+++ b/solr/monitoring/mixin/jsonnetfile.lock.json
@@ -0,0 +1,46 @@
+{
+  "version": 1,
+  "dependencies": [
+    {
+      "source": {
+        "git": {
+          "remote": "https://github.com/grafana/grafonnet.git";,
+          "subdir": "gen/grafonnet-latest"
+        }
+      },
+      "version": "7380c9c64fb973f34c3ec46265621a2b0dee0058",
+      "sum": "V9vAj21qJOc2DlMPDgB1eEjSQU4A+sAA4AXuJ6bd4xc="
+    },
+    {
+      "source": {
+        "git": {
+          "remote": "https://github.com/grafana/grafonnet.git";,
+          "subdir": "gen/grafonnet-v11.4.0"
+        }
+      },
+      "version": "7380c9c64fb973f34c3ec46265621a2b0dee0058",
+      "sum": "aVAX09paQYNOoCSKVpuk1exVIyBoMt/C50QJI+Q/3nA="
+    },
+    {
+      "source": {
+        "git": {
+          "remote": "https://github.com/jsonnet-libs/docsonnet.git";,
+          "subdir": "doc-util"
+        }
+      },
+      "version": "6ac6c69685b8c29c54515448eaca583da2d88150",
+      "sum": "BrAL/k23jq+xy9oA7TWIhUx07dsA/QLm3g7ktCwe//U="
+    },
+    {
+      "source": {
+        "git": {
+          "remote": "https://github.com/jsonnet-libs/xtd.git";,
+          "subdir": ""
+        }
+      },
+      "version": "4d7f8cb24d613430799f9d56809cc6964f35cea9",
+      "sum": "hOrwkOx34tOXqoDVnwuI/Uf/dr9HFFSPWpDPOvnEGrk="
+    }
+  ],
+  "legacyImports": false
+}
diff --git a/solr/monitoring/mixin/license-header.yml 
b/solr/monitoring/mixin/license-header.yml
new file mode 100644
index 00000000000..c8d2335f282
--- /dev/null
+++ b/solr/monitoring/mixin/license-header.yml
@@ -0,0 +1,17 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# This file is generated by mixin/alerts/alerts.libsonnet — do not edit 
directly.
+# To regenerate: cd mixin && make alerts
diff --git a/solr/monitoring/mixin/make.sh b/solr/monitoring/mixin/make.sh
new file mode 100755
index 00000000000..7392e564da7
--- /dev/null
+++ b/solr/monitoring/mixin/make.sh
@@ -0,0 +1,67 @@
+#!/usr/bin/env bash
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# make.sh — Docker-based wrapper around the mixin Makefile.
+#
+# Usage:
+#   ./make.sh              # equivalent to: make all
+#   ./make.sh install all  # equivalent to: make install all
+#   ./make.sh check        # equivalent to: make check
+#   ./make.sh --rebuild    # force-rebuild the Docker image, then make all
+#
+# The Docker image (solr-mixin-make:latest) is built automatically on first
+# use and reused on subsequent runs. Pass --rebuild to force a fresh build
+# (e.g. after updating the Dockerfile or tool versions).
+
+set -euo pipefail
+
+IMAGE="solr-mixin-make:latest"
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+MONITORING_DIR="$(dirname "$SCRIPT_DIR")"
+
+# --- parse flags -------------------------------------------------------------
+REBUILD=false
+MAKE_ARGS=()
+for arg in "$@"; do
+  case "$arg" in
+    --rebuild) REBUILD=true ;;
+    *)         MAKE_ARGS+=("$arg") ;;
+  esac
+done
+
+# --- build image if missing or explicitly requested --------------------------
+if ! docker image inspect "$IMAGE" >/dev/null 2>&1 || [ "$REBUILD" = true ]; 
then
+  echo "[make.sh] Building Docker image $IMAGE ..."
+  docker build -t "$IMAGE" "$SCRIPT_DIR"
+fi
+
+# --- run make inside the container -------------------------------------------
+# Mount the monitoring/ directory as /work so that the Makefile's relative
+# paths (../grafana-solr-dashboard.json, ../prometheus-solr-alerts.yml) resolve
+# correctly to the host filesystem.
+TTY_FLAG=""
+[ -t 0 ] && TTY_FLAG="-t"
+
+# Run as the invoking user, so that the regenerated artifacts and vendor/ end 
up
+# owned by you rather than by root. Without this, a build on native Linux 
leaves
+# files you cannot edit or clean without sudo. The tools are static binaries 
and
+# do not need the user to exist in the image's passwd database, but they do 
need
+# a writable HOME.
+docker run --rm -i $TTY_FLAG \
+  --user "$(id -u):$(id -g)" \
+  -e HOME=/tmp \
+  -v "$MONITORING_DIR:/work" \
+  "$IMAGE" "${MAKE_ARGS[@]:-all}"
diff --git a/solr/monitoring/mixin/mixin.libsonnet 
b/solr/monitoring/mixin/mixin.libsonnet
new file mode 100644
index 00000000000..13902679e7d
--- /dev/null
+++ b/solr/monitoring/mixin/mixin.libsonnet
@@ -0,0 +1,36 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements.  See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// mixin.libsonnet — root entry point for the Solr monitoring mixin.
+//
+// Composes the configuration with the dashboards and alerts, so that both read
+// their settings from the merged root. That late binding is what lets a caller
+// override settings without editing this tree:
+//
+//   (import 'mixin.libsonnet') + { _config+:: { solrSelector: 'job="my-solr"' 
} }
+//
+// Exposes:
+//   grafanaDashboards::  — map of filename -> Grafana dashboard JSON object
+//   prometheusAlerts::   — Prometheus alert groups object
+//
+// Usage:
+//   jsonnet -J vendor -e '(import 
"mixin.libsonnet").grafanaDashboards["grafana-solr-dashboard.json"]' \
+//     > ../grafana-solr-dashboard.json
+//   jsonnet -J vendor -e '(import "mixin.libsonnet").prometheusAlerts' \
+//     | gojsontoyaml > ../prometheus-solr-alerts.yml
+
+(import 'config.libsonnet')
++ (import 'dashboards/dashboards.libsonnet')
++ (import 'alerts/alerts.libsonnet')
diff --git a/solr/monitoring/prometheus-solr-alerts.yml 
b/solr/monitoring/prometheus-solr-alerts.yml
new file mode 100644
index 00000000000..d1cefdcba1a
--- /dev/null
+++ b/solr/monitoring/prometheus-solr-alerts.yml
@@ -0,0 +1,30 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# This file is generated by mixin/alerts/alerts.libsonnet — do not edit 
directly.
+# To regenerate: cd mixin && make alerts
+groups:
+- name: SolrExampleAlerts
+  rules:
+  - alert: SolrNodeDown
+    annotations:
+      description: |
+        Prometheus has failed to scrape {{ $labels.instance }} for 2 minutes.
+        Either the node is down or its metrics endpoint is unreachable.
+      summary: Solr node {{ $labels.instance }} is not responding to scrapes
+    expr: up{job="solr"} == 0
+    for: 2m
+    labels:
+      severity: warning
diff --git a/solr/solr-ref-guide/modules/deployment-guide/deployment-nav.adoc 
b/solr/solr-ref-guide/modules/deployment-guide/deployment-nav.adoc
index e22a51c7be1..da291cbf746 100644
--- a/solr/solr-ref-guide/modules/deployment-guide/deployment-nav.adoc
+++ b/solr/solr-ref-guide/modules/deployment-guide/deployment-nav.adoc
@@ -105,6 +105,7 @@
 ** xref:configuring-logging.adoc[]
 ** xref:opentelemetry.adoc[]
 ** xref:metrics-reporting.adoc[]
+** xref:monitoring-with-prometheus-and-grafana.adoc[]
 ** xref:distributed-tracing.adoc[]
 ** xref:performance-statistics-reference.adoc[]
 ** xref:jmx-with-solr.adoc[]
diff --git 
a/solr/solr-ref-guide/modules/deployment-guide/images/monitoring-with-prometheus-and-grafana/grafana-solr-dashboard.png
 
b/solr/solr-ref-guide/modules/deployment-guide/images/monitoring-with-prometheus-and-grafana/grafana-solr-dashboard.png
index 69f21a4debb..bc7b6ef2841 100644
Binary files 
a/solr/solr-ref-guide/modules/deployment-guide/images/monitoring-with-prometheus-and-grafana/grafana-solr-dashboard.png
 and 
b/solr/solr-ref-guide/modules/deployment-guide/images/monitoring-with-prometheus-and-grafana/grafana-solr-dashboard.png
 differ
diff --git 
a/solr/solr-ref-guide/modules/deployment-guide/images/monitoring-with-prometheus-and-grafana/prometheus-solr-ping.png
 
b/solr/solr-ref-guide/modules/deployment-guide/images/monitoring-with-prometheus-and-grafana/prometheus-solr-ping.png
deleted file mode 100644
index e4bd7f77cfe..00000000000
Binary files 
a/solr/solr-ref-guide/modules/deployment-guide/images/monitoring-with-prometheus-and-grafana/prometheus-solr-ping.png
 and /dev/null differ
diff --git 
a/solr/solr-ref-guide/modules/deployment-guide/images/monitoring-with-prometheus-and-grafana/solr-exporter-diagram.png
 
b/solr/solr-ref-guide/modules/deployment-guide/images/monitoring-with-prometheus-and-grafana/solr-exporter-diagram.png
deleted file mode 100644
index 96efece464d..00000000000
Binary files 
a/solr/solr-ref-guide/modules/deployment-guide/images/monitoring-with-prometheus-and-grafana/solr-exporter-diagram.png
 and /dev/null differ
diff --git 
a/solr/solr-ref-guide/modules/deployment-guide/images/monitoring-with-prometheus-and-grafana/solr-monitoring-diagram.png
 
b/solr/solr-ref-guide/modules/deployment-guide/images/monitoring-with-prometheus-and-grafana/solr-monitoring-diagram.png
new file mode 100644
index 00000000000..dce7b383b46
Binary files /dev/null and 
b/solr/solr-ref-guide/modules/deployment-guide/images/monitoring-with-prometheus-and-grafana/solr-monitoring-diagram.png
 differ
diff --git 
a/solr/solr-ref-guide/modules/deployment-guide/images/monitoring-with-prometheus-and-grafana/solr-monitoring-diagram.svg
 
b/solr/solr-ref-guide/modules/deployment-guide/images/monitoring-with-prometheus-and-grafana/solr-monitoring-diagram.svg
new file mode 100644
index 00000000000..bd01251f8e9
Binary files /dev/null and 
b/solr/solr-ref-guide/modules/deployment-guide/images/monitoring-with-prometheus-and-grafana/solr-monitoring-diagram.svg
 differ
diff --git 
a/solr/solr-ref-guide/modules/deployment-guide/pages/metrics-reporting.adoc 
b/solr/solr-ref-guide/modules/deployment-guide/pages/metrics-reporting.adoc
index 880359491fd..d47dd9ce630 100644
--- a/solr/solr-ref-guide/modules/deployment-guide/pages/metrics-reporting.adoc
+++ b/solr/solr-ref-guide/modules/deployment-guide/pages/metrics-reporting.adoc
@@ -180,6 +180,8 @@ See 
https://prometheus.io/docs/concepts/data_model/[Prometheus Data Model] docum
 
 This endpoint can be used to pull/scrape metrics to a Prometheus server or any 
Prometheus-compatible backend directly from Solr.
 
+TIP: For a worked Prometheus and Grafana setup, including the dashboard and 
alert rules Solr ships, see xref:monitoring-with-prometheus-and-grafana.adoc[].
+
 ==== Prometheus Setup
 
 The `prometheus-config.yml` file needs to be configured for a Prometheus 
server to scrape and collect metrics. A basic configuration for SolrCloud mode 
is as follows:
diff --git 
a/solr/solr-ref-guide/modules/deployment-guide/pages/monitoring-with-prometheus-and-grafana.adoc
 
b/solr/solr-ref-guide/modules/deployment-guide/pages/monitoring-with-prometheus-and-grafana.adoc
new file mode 100644
index 00000000000..3d2d720402b
--- /dev/null
+++ 
b/solr/solr-ref-guide/modules/deployment-guide/pages/monitoring-with-prometheus-and-grafana.adoc
@@ -0,0 +1,171 @@
+= Monitoring Solr with Prometheus and Grafana
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements.  See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+[IMPORTANT]
+====
+*Work in progress — the artifacts shipped today are placeholders.*
+
+Solr 10 removed the standalone `solr-exporter` (the `prometheus-exporter` 
module) and now exposes metrics natively, under OpenTelemetry-based metric 
names.
+Dashboards and alert rules written against the old exporter therefore do not 
work on Solr 10 or later: the process they scraped is gone, and the metric 
names and labels they query have changed.
+Solr's own dashboard and alert rules are consequently being built from 
scratch, panel by panel and rule by rule.
+
+What is in `solr/monitoring/` right now is a two-panel dashboard and a single 
alert, present to prove the generation pipeline works — not a monitoring setup 
to deploy.
+The dashboard worth deploying arrives in 
https://issues.apache.org/jira/browse/SOLR-18534[SOLR-18534], the alert rules 
in https://issues.apache.org/jira/browse/SOLR-18535[SOLR-18535], and the OTLP 
Collector guidance in 
https://issues.apache.org/jira/browse/SOLR-18536[SOLR-18536].
+Expect both files, and this page, to change substantially before they are 
useful.
+====
+
+Solr ships two monitoring artifacts in `solr/monitoring/`:
+
+* `grafana-solr-dashboard.json` — a Grafana dashboard
+* `prometheus-solr-alerts.yml` — a set of Prometheus alert rules
+
+Import these into your own Prometheus + Grafana installation.
+Solr exposes all metrics natively, so no exporter process is required.
+For the full list of available metrics see xref:metrics-reporting.adoc[].
+
+.Solr monitoring architecture — Prometheus pull path and OTLP push path
+image::monitoring-with-prometheus-and-grafana/solr-monitoring-diagram.png[Solr 
monitoring architecture diagram,800]
+
+== The Grafana Dashboard
+
+Import the dashboard by going to *Dashboards → Import* in Grafana and 
uploading `grafana-solr-dashboard.json`.
+
+.The placeholder dashboard, running against two Solr nodes
+image::monitoring-with-prometheus-and-grafana/grafana-solr-dashboard.png[Grafana
 dashboard screenshot,800]
+
+Dropdowns at the top of the dashboard scope what the panels show.
+The placeholder has two: `datasource`, so one import can serve several 
Prometheus instances, and `interval`, which sets the window for rate 
calculations.
+
+[[metrics-collection]]
+== Getting Metrics into Prometheus
+
+There are two ways to get Solr metrics into Prometheus.
+
+[[prometheus-scrape]]
+=== Option 1: Prometheus Pull (recommended)
+
+Prometheus scrapes each Solr node directly — no extra process needed.
+Add a scrape job to your `prometheus.yml`:
+
+[source,yaml]
+----
+scrape_configs:
+  - job_name: solr                        # <1>
+    metrics_path: /solr/admin/metrics     # <2>
+    scrape_interval: 15s
+    static_configs:
+      - targets: ['solr-node1:8983', 'solr-node2:8983']
+----
+<1> The dashboard and alert rules select on this job name.
+    If you use another, set `solrSelector` to match — see <<customizing>>.
+<2> Solr 10.1 and later also serve this at the shorter v2 alias `/api/metrics`.
+
+Scrape every node rather than one: each reports its own JVM and the cores it 
hosts.
+If metrics collection is switched off for a node, both endpoints answer `HTTP 
510` and Prometheus marks the target down.
+
+[[otlp-push]]
+=== Option 2: OTLP Push via OpenTelemetry Collector
+
+When Solr's xref:opentelemetry.adoc[OpenTelemetry module] is enabled, Solr can 
push metrics (and traces) via OTLP to an 
https://opentelemetry.io/docs/collector/[OpenTelemetry Collector], which can 
forward them to Prometheus or to any other OTLP-compatible backend.
+
+NOTE: An example Collector configuration, and the labelling needed to make 
both paths produce identical series, will be documented here as part of 
https://issues.apache.org/jira/browse/SOLR-18536[SOLR-18536].
+
+[[alert-rules]]
+== Alert Rules
+
+Reference `prometheus-solr-alerts.yml` from your `prometheus.yml`:
+
+[source,yaml]
+----
+rule_files:
+  - /path/to/prometheus-solr-alerts.yml
+----
+
+Validate the file before deploying it, and after any edit:
+
+[source,bash]
+----
+promtool check rules prometheus-solr-alerts.yml
+----
+
+Rules carry a `severity` label for routing in Alertmanager.
+One placeholder rule is included today:
+
+[cols="2,1,3",options="header"]
+|===
+| Alert | Severity | Fires when
+| `SolrNodeDown` | warning | A Solr target has failed to scrape for 2 minutes
+|===
+
+NOTE: The rules covering heap, GC, disk, latency, error rate and SolrCloud 
health arrive in https://issues.apache.org/jira/browse/SOLR-18535[SOLR-18535].
+
+[[customizing]]
+== Customizing the Artifacts
+
+Both artifacts are generated from a https://jsonnet.org/[jsonnet] mixin source 
in `solr/monitoring/mixin/`, following the conventions of the 
https://github.com/monitoring-mixins/docs[monitoring-mixins] community.
+Edit the jsonnet, not the generated output.
+
+Tunable settings are centralized in `mixin/config.libsonnet`:
+
+[source,jsonnet]
+----
+{
+  _config:: {
+    solrSelector: 'job="solr"',   // label selector identifying Solr targets
+    rateInterval: '5m',           // window for rate() queries
+  },
+}
+----
+
+`solrSelector` is applied to every dashboard query and alert expression, so 
JVM metrics scraped from other Java services are not read as Solr's.
+Either edit the file and regenerate, or leave this tree untouched and patch 
the configuration from your own jsonnet:
+
+[source,jsonnet]
+----
+(import 'mixin.libsonnet') + { _config+:: { solrSelector: 'job="my-solr"' } }
+----
+
+Regenerating requires `jsonnet`, `jsonnet-bundler` and `gojsontoyaml`, or the 
included Docker wrapper that supplies all three:
+
+[source,bash]
+----
+cd solr/monitoring/mixin
+./make.sh install   # fetch jsonnet dependencies
+./make.sh all       # regenerate both artifacts
+----
+
+See `solr/monitoring/README.md` for the remaining build targets and the local 
toolchain alternative.
+
+[[dev-stack]]
+== Trying It Locally
+
+The Solr source tree carries a docker-compose stack that runs Solr, 
Prometheus, Grafana and Alertmanager together with the generated artifacts 
already wired in.
+It exists to make changes to those artifacts easy to see, and is contributor 
tooling rather than a deployment to model yours on — it is not part of the Solr 
release.
+
+[source,bash]
+----
+cd solr/monitoring/dev
+./stack.sh
+----
+
+Grafana is then on `http://localhost:3000`, opening directly on the 
provisioned dashboard, with Prometheus on `:9090` and Alertmanager on `:9093`.
+`./stack.sh --local-solr` skips the bundled Solr nodes and scrapes a Solr you 
are already running.
+
+[[distributed-tracing]]
+== Metrics and Tracing Together
+
+The same xref:opentelemetry.adoc[OpenTelemetry] instrumentation that produces 
these metrics also emits xref:distributed-tracing.adoc[distributed traces], so 
a latency spike on the dashboard can be followed into the traces of the 
requests that caused it.
+Both signals are exported via OTLP and can flow through the same Collector 
pipeline to a unified observability backend, or independently to specialized 
ones.

Reply via email to