This is an automated email from the ASF dual-hosted git repository.
spetz pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iggy-website.git
The following commit(s) were added to refs/heads/main by this push:
new d5afcead8 docs: add a Grafana dashboard tutorial (#126)
d5afcead8 is described below
commit d5afcead8201d5b5d98d0fd0e3f4675f44d3c92c
Author: Justin Mclean <[email protected]>
AuthorDate: Tue Oct 6 18:16:43 2026 +1100
docs: add a Grafana dashboard tutorial (#126)
Adds a second tutorial to the Tutorials section: run an Iggy server with
Prometheus and Grafana in Docker Compose and watch the broker on a
dashboard. Run end to end against apache/iggy:0.9.0. Builds on #125, so
the first two commits are from that PR and drop out once it merges.
---
content/docs/meta.json | 1 +
content/docs/tutorials/event-driven-service.mdx | 280 ++++++++++++++++++++++++
content/docs/tutorials/grafana-dashboard.mdx | 272 +++++++++++++++++++++++
content/docs/tutorials/meta.json | 4 +
4 files changed, 557 insertions(+)
diff --git a/content/docs/meta.json b/content/docs/meta.json
index a8acc27e0..458a34f4d 100644
--- a/content/docs/meta.json
+++ b/content/docs/meta.json
@@ -3,6 +3,7 @@
"pages": [
"index",
"introduction",
+ "tutorials",
"troubleshooting",
"server",
"clustering",
diff --git a/content/docs/tutorials/event-driven-service.mdx
b/content/docs/tutorials/event-driven-service.mdx
new file mode 100644
index 000000000..85db2d3c2
--- /dev/null
+++ b/content/docs/tutorials/event-driven-service.mdx
@@ -0,0 +1,280 @@
+---
+title: An event-driven service with a consumer group
+description: "Build a small order-processing service in Python: key messages
by customer, share a topic between two service instances with a consumer group,
and see them take over from each other and resume."
+---
+
+You will build a small order-processing service. A producer writes order
events to a topic, keyed by customer. Two copies of the service share the topic
through a consumer group. You stop one and watch the other take over, then
start it again and see it resume where it left off.
+
+## Before we start
+
+You need Docker, Python 3.10 to 3.13, and the Python SDK in a virtual
environment. On Linux the server needs kernel 5.19 or newer, but macOS has no
kernel requirement (see [System
requirements](/docs/server/introduction#system-requirements)).
+
+```bash
+python -m venv .venv
+source .venv/bin/activate
+pip install apache-iggy
+```
+
+Consumer groups need a binary transport, so this tutorial uses TCP. HTTP
cannot join a group (see [Consumer
groups](/docs/introduction/concepts#consumer-groups) and the
[FAQ](/docs/faq/faq)).
+
+## Start the server
+
+```bash
+docker run --rm --name iggy \
+ --cap-add=SYS_NICE --security-opt seccomp=unconfined --ulimit memlock=-1:-1 \
+ -p 8090:8090 \
+ -e IGGY_TCP_ADDRESS=0.0.0.0:8090 \
+ -e IGGY_NODE_ADVERTISED_ADDRESS=localhost \
+ -e IGGY_ROOT_USERNAME=iggy -e IGGY_ROOT_PASSWORD=iggy \
+ -e IGGY_SHARDING_CPU_ALLOCATION=all \
+ apache/iggy:0.9.0
+```
+
+The flags are explained on [Docker & Helm](/docs/server/docker).
`IGGY_SHARDING_CPU_ALLOCATION=all` turns off NUMA binding, which Docker Desktop
does not support.
+
+## The producer
+
+Save as `orders_producer.py`.
+
+```python
+import asyncio
+import json
+import os
+import random
+
+from apache_iggy import IggyClient, Partitioning, SendMessage
+
+CONNECTION_STRING = os.environ.get(
+ "IGGY_CONNECTION_STRING", "iggy+tcp://iggy:[email protected]:8090"
+)
+STREAM = "shop"
+TOPIC = "orders"
+PARTITIONS = 3
+CUSTOMERS = ["ada", "bob", "cy", "dee", "eve"]
+
+
+async def main():
+ client = IggyClient.from_connection_string(CONNECTION_STRING)
+ await client.connect()
+
+ # Create only what is missing, so the producer can be run again.
+ if await client.get_stream(STREAM) is None:
+ await client.create_stream(name=STREAM)
+ if await client.get_topic(STREAM, TOPIC) is None:
+ await client.create_topic(
+ stream=STREAM, name=TOPIC, partitions_count=PARTITIONS
+ )
+
+ for order_id in range(1, 16):
+ customer = random.choice(CUSTOMERS)
+ event = {
+ "order_id": order_id,
+ "customer": customer,
+ "total": round(random.uniform(5, 200), 2),
+ }
+ # One partition per customer: the key is hashed, so every order
+ # from the same customer lands on the same partition, in order.
+ response = await client.send_messages(
+ stream=STREAM,
+ topic=TOPIC,
+ partitioning=Partitioning.messages_key(customer),
+ messages=[SendMessage(json.dumps(event))],
+ )
+ confirmation = response.confirmations[0]
+ print(
+ f"order {order_id:>2} customer={customer:<3} "
+ f"-> partition {confirmation.partition_id} offset
{confirmation.base_offset}"
+ )
+
+
+asyncio.run(main())
+```
+
+`Partitioning.messages_key` hashes the key to pick the partition, so orders
for one customer share a partition and stay in order. The connection string
comes from the environment, so the same script works against a server on
another host (see [Connection strings](/docs/sdk/connection-strings)).
+
+Run it:
+
+```bash
+python orders_producer.py
+```
+
+This is the output from one run. Yours will differ because customers are
picked at random.
+
+```
+order 1 customer=eve -> partition 2 offset 0
+order 2 customer=dee -> partition 1 offset 0
+order 3 customer=dee -> partition 1 offset 1
+order 4 customer=eve -> partition 2 offset 1
+order 5 customer=eve -> partition 2 offset 2
+order 6 customer=bob -> partition 2 offset 3
+order 7 customer=ada -> partition 2 offset 4
+order 8 customer=dee -> partition 1 offset 2
+order 9 customer=cy -> partition 0 offset 0
+order 10 customer=bob -> partition 2 offset 5
+order 11 customer=eve -> partition 2 offset 6
+order 12 customer=bob -> partition 2 offset 7
+order 13 customer=cy -> partition 0 offset 1
+order 14 customer=dee -> partition 1 offset 3
+order 15 customer=dee -> partition 1 offset 4
+```
+
+## The service
+
+Save as `order_service.py`.
+
+```python
+import asyncio
+import json
+import os
+import signal
+import sys
+
+from apache_iggy import (
+ AutoCommit,
+ AutoCommitAfter,
+ IggyClient,
+ PollingStrategy,
+ ReceiveMessage,
+)
+
+CONNECTION_STRING = os.environ.get(
+ "IGGY_CONNECTION_STRING", "iggy+tcp://iggy:[email protected]:8090"
+)
+STREAM = "shop"
+TOPIC = "orders"
+GROUP = "order-processors"
+INSTANCE = sys.argv[1] if len(sys.argv) > 1 else "service"
+
+
+async def main():
+ client = IggyClient.from_connection_string(CONNECTION_STRING)
+ await client.connect()
+
+ # Joins the group (creating it if needed). The server assigns this
+ # member a share of the partitions; Next() resumes after the group's
+ # stored offset, and the offset is stored after each message is handled.
+ consumer = await client.consumer_group(
+ GROUP,
+ STREAM,
+ TOPIC,
+ polling_strategy=PollingStrategy.Next(),
+ auto_commit=AutoCommit.After(AutoCommitAfter.ConsumingEachMessage()),
+ )
+
+ shutdown = asyncio.Event()
+ loop = asyncio.get_running_loop()
+ for sig in (signal.SIGINT, signal.SIGTERM):
+ loop.add_signal_handler(sig, shutdown.set)
+
+ async def handle(message: ReceiveMessage) -> None:
+ event = json.loads(message.payload())
+ print(
+ f"[{INSTANCE}] partition {message.partition_id()} offset
{message.offset()}: "
+ f"order {event['order_id']} for {event['customer']} total
{event['total']}",
+ flush=True,
+ )
+
+ print(f"[{INSTANCE}] joined group {GROUP}, waiting for orders", flush=True)
+ await consumer.consume_messages(handle, shutdown)
+ print(f"[{INSTANCE}] shutting down", flush=True)
+
+
+asyncio.run(main())
+```
+
+`consume_messages` runs until the shutdown event is set. Ctrl-C, or a SIGTERM
from a supervisor, sets it, and the current message finishes before the call
returns. The instance name is only used in the log.
+
+`AutoCommit.After` stores the offset once the handler returns, even if it
raised an exception. Handle errors inside `handle`, or a failed message is
skipped rather than retried.
+
+## Run it
+
+**One instance.** Start the service in a second terminal:
+
+```bash
+python order_service.py svc-a
+```
+
+It is the only member, so it gets all three partitions and drains what the
producer wrote:
+
+```
+[svc-a] joined group order-processors, waiting for orders
+[svc-a] partition 0 offset 0: order 9 for cy total 24.91
+[svc-a] partition 0 offset 1: order 13 for cy total 41.86
+[svc-a] partition 1 offset 0: order 2 for dee total 56.72
+[svc-a] partition 1 offset 1: order 3 for dee total 103.83
+[svc-a] partition 1 offset 2: order 8 for dee total 99.38
+[svc-a] partition 1 offset 3: order 14 for dee total 13.7
+[svc-a] partition 1 offset 4: order 15 for dee total 42.16
+[svc-a] partition 2 offset 0: order 1 for eve total 45.66
+[svc-a] partition 2 offset 1: order 4 for eve total 105.5
+[svc-a] partition 2 offset 2: order 5 for eve total 83.05
+[svc-a] partition 2 offset 3: order 6 for bob total 42.6
+[svc-a] partition 2 offset 4: order 7 for ada total 130.19
+[svc-a] partition 2 offset 5: order 10 for bob total 73.41
+[svc-a] partition 2 offset 6: order 11 for eve total 70.27
+[svc-a] partition 2 offset 7: order 12 for bob total 13.56
+```
+
+**Scale out.** In a third terminal start a second instance, then run the
producer again:
+
+```bash
+python order_service.py svc-b
+```
+
+```bash
+python orders_producer.py
+```
+
+The server rebalances the group. In this run `svc-b` took partition 2 and
`svc-a` kept 0 and 1, and no order was printed by both.
+
+```
+[svc-b] joined group order-processors, waiting for orders
+[svc-b] partition 2 offset 8: order 1 for eve total 6.29
+[svc-b] partition 2 offset 9: order 2 for eve total 30.1
+[svc-b] partition 2 offset 10: order 3 for ada total 181.11
+[svc-b] partition 2 offset 11: order 4 for ada total 82.09
+[svc-b] partition 2 offset 12: order 8 for eve total 24.11
+[svc-b] partition 2 offset 13: order 9 for eve total 65.43
+[svc-b] partition 2 offset 14: order 10 for bob total 60.97
+[svc-b] partition 2 offset 15: order 11 for eve total 41.1
+[svc-b] partition 2 offset 16: order 12 for ada total 47.29
+[svc-b] partition 2 offset 17: order 13 for eve total 11.01
+```
+
+```
+[svc-a] partition 0 offset 2: order 5 for cy total 171.77
+[svc-a] partition 1 offset 5: order 7 for dee total 178.08
+[svc-a] partition 0 offset 3: order 6 for cy total 152.96
+[svc-a] partition 0 offset 4: order 15 for cy total 194.45
+[svc-a] partition 1 offset 6: order 14 for dee total 81.75
+```
+
+**Scale in.** Press Ctrl-C in the `svc-a` terminal. It prints `[svc-a]
shutting down` and exits. Run the producer again. `svc-b` now owns every
partition and receives all 15 orders (output trimmed):
+
+```
+[svc-b] partition 0 offset 5: order 1 for cy total 141.92
+[svc-b] partition 2 offset 18: order 2 for bob total 91.52
+[svc-b] partition 2 offset 19: order 3 for bob total 113.24
+...
+[svc-b] partition 1 offset 9: order 14 for dee total 159.5
+[svc-b] partition 2 offset 25: order 12 for ada total 149.35
+[svc-b] partition 2 offset 26: order 15 for ada total 81.49
+```
+
+**Resume.** Start `svc-a` again. It prints only its `joined` line, because the
group's stored offsets mean nothing is replayed. Run the producer once more and
`svc-a` gets partition 2 back:
+
+```
+[svc-a] joined group order-processors, waiting for orders
+[svc-a] partition 2 offset 27: order 1 for eve total 50.28
+[svc-a] partition 2 offset 28: order 2 for eve total 188.94
+...
+[svc-a] partition 2 offset 37: order 15 for bob total 158.09
+```
+
+When a partition moves, the server stops the old owner polling it before
handing it to the new one, and waits up to 30 seconds by default (see [How does
consumer group rebalancing
work?](/docs/faq/faq#q-how-does-consumer-group-rebalancing-work)).
+
+## Running it again
+
+The producer creates the stream and topic only if they are missing, so it can
be run any number of times. A restarted service resumes from the group's stored
offset. To start from nothing, stop the container, and `--rm` removes it along
with its data.
+
+The service is the same consumer shape as the [high-level consumer
example](https://github.com/apache/iggy/blob/master/examples/python/high-level/consumer.py)
in the repository, with a JSON handler and signal handling added.
diff --git a/content/docs/tutorials/grafana-dashboard.mdx
b/content/docs/tutorials/grafana-dashboard.mdx
new file mode 100644
index 000000000..122f03776
--- /dev/null
+++ b/content/docs/tutorials/grafana-dashboard.mdx
@@ -0,0 +1,272 @@
+---
+title: A Grafana dashboard for the broker
+description: "Run an Iggy server with Prometheus and Grafana in Docker
Compose, and watch the broker on a dashboard."
+---
+
+You will run an Iggy server with Prometheus and Grafana, and watch the broker
on a dashboard as you send it messages.
+
+## Before we start
+
+All you need is Docker with Compose, since everything else runs in containers.
+
+## The stack
+
+Create a directory with these files.
+
+`compose.yml`:
+
+```yaml
+services:
+ iggy:
+ image: apache/iggy:0.9.0
+ cap_add:
+ - SYS_NICE
+ security_opt:
+ - seccomp:unconfined
+ ulimits:
+ memlock:
+ soft: -1
+ hard: -1
+ environment:
+ - IGGY_ROOT_USERNAME=iggy
+ - IGGY_ROOT_PASSWORD=iggy
+ - IGGY_TCP_ADDRESS=0.0.0.0:8090
+ - IGGY_HTTP_ADDRESS=0.0.0.0:3000
+ - IGGY_NODE_ADVERTISED_ADDRESS=localhost
+ - IGGY_QUIC_ENABLED=false
+ - IGGY_WEBSOCKET_ENABLED=false
+ # Needed on Docker Desktop, which does not support NUMA binding.
+ - IGGY_SHARDING_CPU_ALLOCATION=all
+ ports:
+ - "8090:8090"
+ - "3000:3000"
+
+ prometheus:
+ image: prom/prometheus:v3.13.3
+ volumes:
+ - ./prometheus:/etc/prometheus:ro
+ ports:
+ - "9090:9090"
+ depends_on:
+ - iggy
+
+ grafana:
+ image: grafana/grafana:13.2.2
+ environment:
+ - GF_SECURITY_ADMIN_PASSWORD=admin
+ volumes:
+ - ./grafana/provisioning:/etc/grafana/provisioning:ro
+ - ./grafana/dashboards:/var/lib/grafana/dashboards:ro
+ ports:
+ - "3001:3000"
+ depends_on:
+ - prometheus
+```
+
+The server flags are explained on [Docker & Helm](/docs/server/docker), and
metrics are on by default. Grafana is on port 3001 because the server already
uses 3000.
+
+`prometheus/prometheus.yml`:
+
+```yaml
+global:
+ scrape_interval: 5s
+
+scrape_configs:
+ - job_name: iggy
+ metrics_path: /metrics
+ # /metrics needs a token. Prometheus reads this file every few
+ # seconds, so you can fill it in later without a restart.
+ authorization:
+ type: Bearer
+ credentials_file: /etc/prometheus/token
+ static_configs:
+ - targets: ["iggy:3000"]
+```
+
+Create an empty token file:
+
+```bash
+touch prometheus/token
+```
+
+`grafana/provisioning/datasources/prometheus.yaml`:
+
+```yaml
+apiVersion: 1
+datasources:
+ - name: Prometheus
+ type: prometheus
+ uid: prometheus
+ access: proxy
+ url: http://prometheus:9090
+ isDefault: true
+```
+
+`grafana/provisioning/dashboards/iggy.yaml`:
+
+```yaml
+apiVersion: 1
+providers:
+ - name: iggy
+ folder: Iggy
+ type: file
+ options:
+ path: /var/lib/grafana/dashboards
+```
+
+`grafana/dashboards/iggy.json`:
+
+```json
+{
+ "title": "Apache Iggy broker",
+ "uid": "iggy-broker",
+ "schemaVersion": 39,
+ "refresh": "10s",
+ "time": { "from": "now-15m", "to": "now" },
+ "panels": [
+ {
+ "id": 1, "type": "stat", "title": "Scrape health",
+ "gridPos": { "x": 0, "y": 0, "w": 4, "h": 4 },
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "targets": [ { "expr": "up{job=\"iggy\"}", "refId": "A" } ],
+ "fieldConfig": { "defaults": { "thresholds": { "mode": "absolute",
"steps": [ { "color": "red", "value": null }, { "color": "green", "value": 1 }
] } } }
+ },
+ {
+ "id": 2, "type": "stat", "title": "Messages stored",
+ "gridPos": { "x": 4, "y": 0, "w": 4, "h": 4 },
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "targets": [ { "expr": "messages", "refId": "A" } ]
+ },
+ {
+ "id": 3, "type": "stat", "title": "Connected clients",
+ "gridPos": { "x": 8, "y": 0, "w": 4, "h": 4 },
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "targets": [ { "expr": "clients", "refId": "A" } ]
+ },
+ {
+ "id": 4, "type": "stat", "title": "Streams / topics / partitions /
segments",
+ "gridPos": { "x": 12, "y": 0, "w": 12, "h": 4 },
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "targets": [
+ { "expr": "streams", "refId": "A", "legendFormat": "streams" },
+ { "expr": "topics", "refId": "B", "legendFormat": "topics" },
+ { "expr": "partitions", "refId": "C", "legendFormat": "partitions" },
+ { "expr": "segments", "refId": "D", "legendFormat": "segments" }
+ ]
+ },
+ {
+ "id": 5, "type": "timeseries", "title": "HTTP requests / s",
+ "gridPos": { "x": 0, "y": 4, "w": 12, "h": 8 },
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "targets": [ { "expr": "rate(http_requests_total[1m])", "refId": "A",
"legendFormat": "{{instance}}" } ]
+ },
+ {
+ "id": 6, "type": "timeseries", "title": "Messages added per minute",
+ "gridPos": { "x": 12, "y": 4, "w": 12, "h": 8 },
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "targets": [ { "expr": "delta(messages[1m])", "refId": "A",
"legendFormat": "{{instance}}" } ]
+ },
+ {
+ "id": 7, "type": "timeseries", "title": "Client requests denied (queue
full) / s, per shard",
+ "gridPos": { "x": 0, "y": 12, "w": 18, "h": 8 },
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "targets": [ { "expr":
"rate(client_requests_denied_queue_full_total[1m])", "refId": "A",
"legendFormat": "shard {{shard}}" } ]
+ },
+ {
+ "id": 9, "type": "stat", "title": "Stats rollup underflows",
+ "gridPos": { "x": 18, "y": 12, "w": 6, "h": 8 },
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "targets": [ { "expr": "stats_rollup_underflows_total", "refId": "A" } ],
+ "fieldConfig": { "defaults": { "thresholds": { "mode": "absolute",
"steps": [ { "color": "green", "value": null }, { "color": "orange", "value": 1
} ] } } }
+ }
+ ]
+}
+```
+
+## Start it
+
+```bash
+docker compose up -d
+```
+
+Prometheus starts scraping straight away, but the server turns it away because
it has no token. You get the same answer from curl:
+
+```bash
+curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000/metrics
+```
+
+```
+401
+```
+
+The [Prometheus targets page](http://localhost:9090/targets) also shows the
`iggy` job as down.
+
+## Create a token for Prometheus
+
+Create a personal access token with the CLI and write it to the file
Prometheus reads:
+
+```bash
+docker compose exec -T iggy iggy --username iggy --password iggy \
+ pat create metrics-scraper 2>/dev/null | sed -n 's/^Token: //p' >
prometheus/token
+```
+
+Check that the token works:
+
+```bash
+curl -s -o /dev/null -w "%{http_code}\n" \
+ -H "Authorization: Bearer $(cat prometheus/token)"
http://localhost:3000/metrics
+```
+
+```
+200
+```
+
+Within a few seconds the Prometheus targets page shows the `iggy` job as up.
+
+## Send some messages
+
+Use the CLI to create a stream and topic, then send 60 messages to it:
+
+```bash
+I="docker compose exec -T iggy iggy --username iggy --password iggy"
+$I stream create shop 2>/dev/null
+$I topic create shop orders 3 none 2>/dev/null
+for i in $(seq 1 60); do
+ $I message send --partition-id $((i % 3)) shop orders
"{\"order_id\":$i,\"total\":$((i*3)).50}" > /dev/null 2>&1
+done
+```
+
+Check the message count in Prometheus:
+
+```bash
+curl -s http://localhost:9090/api/v1/query --data-urlencode 'query=messages' |
jq -r '.data.result[0].value[1]'
+```
+
+```
+60
+```
+
+## Open Grafana
+
+Open [http://localhost:3001](http://localhost:3001), sign in as `admin` with
password `admin`, and find the "Apache Iggy broker" dashboard in the Iggy
folder. Messages stored reads 60 and the rate panels show the burst, so send
another and watch them move.
+
+## What the server exports
+
+The server exports about forty metrics, including `http_requests_total`,
`streams`, `topics`, `partitions`, `segments`, `messages`, `users` and
`clients`. To list them all:
+
+```bash
+curl -s -H "Authorization: Bearer $(cat prometheus/token)" \
+ http://localhost:3000/metrics | grep '^# HELP'
+```
+
+## Running it again
+
+To stop everything:
+
+```bash
+docker compose down
+```
+
+Your files stay in the directory, so `docker compose up -d` starts it all
again. The server starts empty each time, so create a new token and send some
messages before you open the dashboard.
+
+To add a panel, edit `grafana/dashboards/iggy.json` and Grafana picks up the
change within a few seconds. Watch the denied requests panel: if it starts to
climb, the server is too busy to take more requests. The server's metrics
settings are under [`[http.metrics]`](/docs/server/configuration#httpmetrics)
in the configuration reference.
diff --git a/content/docs/tutorials/meta.json b/content/docs/tutorials/meta.json
new file mode 100644
index 000000000..77e65d590
--- /dev/null
+++ b/content/docs/tutorials/meta.json
@@ -0,0 +1,4 @@
+{
+ "title": "Tutorials",
+ "pages": ["event-driven-service", "grafana-dashboard"]
+}