diff --git a/distribution/src/test/java/com/predic8/membrane/tutorials/operation/LokiTutorialTest.java b/distribution/src/test/java/com/predic8/membrane/tutorials/operation/LokiTutorialTest.java
new file mode 100644
index 0000000000..c5a8d9adc7
--- /dev/null
+++ b/distribution/src/test/java/com/predic8/membrane/tutorials/operation/LokiTutorialTest.java
@@ -0,0 +1,156 @@
+/* Copyright 2026 predic8 GmbH, www.predic8.com
+
+ Licensed 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. */
+
+package com.predic8.membrane.tutorials.operation;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.predic8.membrane.core.exchange.Exchange;
+import com.predic8.membrane.core.interceptor.AbstractInterceptor;
+import com.predic8.membrane.core.interceptor.Outcome;
+import com.predic8.membrane.core.proxies.ServiceProxy;
+import com.predic8.membrane.core.proxies.ServiceProxyKey;
+import com.predic8.membrane.core.router.DefaultRouter;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import static com.predic8.membrane.core.http.Response.ok;
+import static io.restassured.RestAssured.given;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * Verifies tutorial step 70-Record-Messages-In-Loki.yaml: lokiExchangeStore pushes every finished exchange to
+ * /loki/api/v1/push, labelled with job and the name of the API.
+ *
+ *
A local Membrane router stands in for Loki on the port the tutorial configures, so the
+ * lesson is verified end-to-end without a Loki container. What the tutorial's LogQL steps
+ * promise — a job="membrane-instance-1" stream per API, and lines whose JSON carries method,
+ * URI and status code — is exactly what is asserted here.
+ */
+public class LokiTutorialTest extends AbstractOperationTutorialTest {
+
+ private static final int LOKI_MOCK_PORT = 3100;
+ private static final ObjectMapper om = new ObjectMapper();
+
+ private final List pushes = new ArrayList<>();
+
+ private DefaultRouter lokiMock;
+
+ @Override
+ protected String getTutorialYaml() {
+ return "70-Record-Messages-In-Loki.yaml";
+ }
+
+ /**
+ * Hides {@code AbstractSampleMembraneStartStopTestcase.startMembrane()} so the Loki stand-in
+ * is listening before Membrane starts and no push can be missed.
+ */
+ @BeforeEach
+ void startMembrane() throws Exception {
+ startLokiMock();
+ process = startServiceProxyScript();
+ }
+
+ @AfterEach
+ void stopLokiMock() {
+ if (lokiMock != null)
+ lokiMock.stop();
+ }
+
+ @Test
+ void exchangesArePushedToLokiLabelledByJobAndApi() throws Exception {
+ // @formatter:off
+ given()
+ .when()
+ .get("http://localhost:2001")
+ .then()
+ .statusCode(200);
+
+ given()
+ .when()
+ .get("http://localhost:2002")
+ .then()
+ .statusCode(404);
+ // @formatter:on
+
+ assertEquals("GET / -> 200", waitForLogLine("Backend A"));
+ assertEquals("GET / -> 404", waitForLogLine("Backend B"));
+ }
+
+ /**
+ * Waits for a log line pushed for the given API and renders it the way the tutorial's
+ * {@code line_format "{{.request_method}} {{.request_uri}} -> {{.response_statusCode}}"}
+ * would.
+ */
+ private String waitForLogLine(String api) throws Exception {
+ // The store batches and pushes every updateIntervalMs (1000 by default).
+ for (int i = 0; i < 100; i++) {
+ JsonNode line = findLogLine(api);
+ if (line != null)
+ return "%s %s -> %d".formatted(
+ line.get("request").get("method").textValue(),
+ line.get("request").get("uri").textValue(),
+ line.get("response").get("statusCode").intValue());
+ Thread.sleep(100);
+ }
+ throw new AssertionError("No exchange pushed to Loki for API '%s' within 10s. Pushes seen: %s"
+ .formatted(api, pushes));
+ }
+
+ private JsonNode findLogLine(String api) throws Exception {
+ synchronized (pushes) {
+ for (JsonNode push : pushes)
+ for (JsonNode stream : push.get("streams")) {
+ JsonNode labels = stream.get("stream");
+ if (!"membrane-instance-1".equals(labels.get("job").textValue()))
+ continue;
+ if (!api.equals(labels.get("api").textValue()))
+ continue;
+ JsonNode values = stream.get("values");
+ if (!values.isEmpty())
+ // values[i] is [ "", "" ]
+ return om.readTree(values.get(0).get(1).textValue());
+ }
+ }
+ return null;
+ }
+
+ private void startLokiMock() throws Exception {
+ ServiceProxy sp = new ServiceProxy(new ServiceProxyKey(LOKI_MOCK_PORT), null, 0);
+ sp.getFlow().add(new AbstractInterceptor() {
+ @Override
+ public Outcome handleRequest(Exchange exc) {
+ if (exc.getRequest().getUri().equals("/loki/api/v1/push")) {
+ try {
+ synchronized (pushes) {
+ pushes.add(om.readTree(exc.getRequest().getBodyAsStringDecoded()));
+ }
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+ exc.setResponse(ok().build());
+ return Outcome.RETURN;
+ }
+ });
+
+ lokiMock = new DefaultRouter();
+ lokiMock.add(sp);
+ lokiMock.start();
+ }
+}
diff --git a/distribution/tutorials/operation/70-Record-Messages-In-Loki.yaml b/distribution/tutorials/operation/70-Record-Messages-In-Loki.yaml
new file mode 100644
index 0000000000..a2e43bc99a
--- /dev/null
+++ b/distribution/tutorials/operation/70-Record-Messages-In-Loki.yaml
@@ -0,0 +1,93 @@
+# yaml-language-server: $schema=https://www.membrane-api.io/v7.3.1.json
+#
+# Tutorial: Sending Exchanges to Grafana Loki
+#
+# The prometheus plugin (50-Prometheus.yaml) gives you counters and timings, but it
+# cannot tell you what a single call looked like. lokiExchangeStore fills that gap:
+# it pushes every finished exchange - request and response, headers and bodies - to
+# Grafana Loki as one JSON log line, where you can search it with LogQL.
+#
+# 1.) Start Loki and Grafana:
+# docker compose -f loki/docker-compose.yml up -d
+#
+# 2.) Start Membrane:
+# Linux/Mac: ./membrane.sh -c 70-Record-Messages-In-Loki.yaml
+# Windows: membrane.cmd -c 70-Record-Messages-In-Loki.yaml
+#
+# 3.) Generate traffic on the two APIs:
+# curl http://localhost:2001
+# curl http://localhost:2002
+#
+# 4.) Open Grafana at http://localhost:3000 and log in with admin / admin.
+# Go to Explore (compass icon), pick the Loki datasource, switch the query
+# editor to "Code" and run:
+#
+# {job="membrane-instance-1"}
+#
+# You should see one log line per call, each holding a complete exchange
+# as JSON.
+#
+# 5.) Unpack that JSON with the 'json' stage and pick out single fields:
+#
+# {job="membrane-instance-1"} | json | line_format "{{.request_method}} {{.request_uri}} -> {{.response_statusCode}}"
+#
+# You should see one line per call:
+#
+# GET / -> 200
+# GET / -> 404
+#
+# 6.) Every line also carries an 'api' label with the name of the API the
+# exchange passed through, so you can narrow the search to one API:
+#
+# {job="membrane-instance-1", api="Backend B"} | json
+#
+# Labels are indexed by Loki, so filtering on 'api' or 'job' is much cheaper
+# than filtering on a field inside the JSON.
+#
+# Notes:
+# Only finished exchanges are pushed. Loki cannot update a log line that has
+# already been written, so Membrane waits until an exchange is complete and
+# then writes it once. Exchanges are batched and sent every second; set
+# updateIntervalMs to change that.
+#
+# Request and response bodies are byte arrays and therefore Base64-encoded
+# inside the JSON. Bodies larger than maxBodySize (100000 by default) are
+# truncated.
+#
+# The store is write-only: the Membrane admin console cannot browse exchanges
+# that live in Loki. Use Grafana for that.
+#
+# Troubleshooting:
+# "While pushing N exchanges to Loki" in the Membrane log means Loki was not
+# reachable at the configured url. Membrane keeps serving requests and keeps
+# trying, so once Loki is back up later exchanges arrive. Check the containers:
+# docker compose -f loki/docker-compose.yml ps
+#
+# This Grafana also uses port 3000. If you still have the one from
+# 60-Grafana.yaml running, stop it first:
+# docker compose -f grafana/docker-compose.yml down
+
+components:
+ # A single exchangeStore declared here is picked up automatically: every
+ # exchange passing through the gateway is handed to it.
+ exchangeStore:
+ lokiExchangeStore:
+ url: http://localhost:3100
+ job: membrane-instance-1
+
+---
+# The API name becomes the 'api' label in Loki, so name your APIs.
+api:
+ name: Backend A
+ port: 2001
+ flow:
+ - return:
+ status: 200
+
+---
+api:
+ name: Backend B
+ port: 2002
+ flow:
+ - return:
+ status: 404
diff --git a/distribution/tutorials/operation/loki/docker-compose.yml b/distribution/tutorials/operation/loki/docker-compose.yml
new file mode 100644
index 0000000000..f775e6c71b
--- /dev/null
+++ b/distribution/tutorials/operation/loki/docker-compose.yml
@@ -0,0 +1,19 @@
+services:
+ loki:
+ image: grafana/loki:2.9.8
+ container_name: loki
+ ports:
+ - "3100:3100"
+ command: -config.file=/etc/loki/local-config.yaml
+
+ grafana:
+ image: grafana/grafana:9.5.2
+ container_name: grafana-loki
+ ports:
+ - "3000:3000"
+ restart: unless-stopped
+ environment:
+ - GF_SECURITY_ADMIN_USER=admin
+ - GF_SECURITY_ADMIN_PASSWORD=admin
+ volumes:
+ - ./provisioning/datasources:/etc/grafana/provisioning/datasources
diff --git a/distribution/tutorials/operation/loki/provisioning/datasources/datasource.yml b/distribution/tutorials/operation/loki/provisioning/datasources/datasource.yml
new file mode 100644
index 0000000000..97ff7d5cd1
--- /dev/null
+++ b/distribution/tutorials/operation/loki/provisioning/datasources/datasource.yml
@@ -0,0 +1,8 @@
+apiVersion: 1
+datasources:
+ - name: Loki
+ type: loki
+ uid: P8E80F9AEF21F6940
+ access: proxy
+ url: http://loki:3100
+ isDefault: true