Grafana Loki Adapter
The Loki adapter pushes wide events to Grafana Loki via its push API. It works with a self-hosted single-tenant instance, a multi-tenant deployment, and Grafana Cloud.
Each event is pushed as a JSON log line under a small, low-cardinality label set. That distinction matters in Loki: labels are indexed and billed by cardinality, while the log line is searched at query time. evlog labels only service, environment, and level by default, and leaves everything else — requestId, path, user, your custom fields — in the line, queryable with | json.
Add the Grafana Loki drain adapter
Installation
The Loki adapter comes bundled with evlog:
import { createLokiDrain } from 'evlog/loki'
Quick Start
Set LOKI_ENDPOINT and wire the drain into your framework.
// server/plugins/evlog.ts
import { createLokiDrain } from 'evlog/loki'
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('evlog:drain', createLokiDrain())
})
import { Hono } from 'hono'
import { evlog } from 'evlog/hono'
import { createLokiDrain } from 'evlog/loki'
const app = new Hono()
app.use(evlog({ drain: createLokiDrain() }))
import express from 'express'
import { evlog } from 'evlog/express'
import { createLokiDrain } from 'evlog/loki'
const app = express()
app.use(evlog({ drain: createLokiDrain() }))
import Fastify from 'fastify'
import { evlog } from 'evlog/fastify'
import { createLokiDrain } from 'evlog/loki'
const app = Fastify()
await app.register(evlog, { drain: createLokiDrain() })
import { Elysia } from 'elysia'
import { evlog } from 'evlog/elysia'
import { createLokiDrain } from 'evlog/loki'
const app = new Elysia().use(evlog({ drain: createLokiDrain() }))
import { Module } from '@nestjs/common'
import { EvlogModule } from 'evlog/nestjs'
import { createLokiDrain } from 'evlog/loki'
@Module({
imports: [EvlogModule.forRoot({ drain: createLokiDrain() })],
})
export class AppModule {}
import { initLogger } from 'evlog'
import { createLokiDrain } from 'evlog/loki'
initLogger({
env: { service: 'my-app' },
drain: createLokiDrain(),
})
Configuration
Environment variables
| Variable | Required | Description |
|---|---|---|
LOKI_ENDPOINT | Yes | Base URL of the Loki instance, without the push path (LOKI_URL also accepted) |
LOKI_API_KEY | No | API token. Sent as Basic with LOKI_USER, otherwise as Bearer (GRAFANA_API_KEY also accepted) |
LOKI_USER | No | Grafana Cloud instance ID. Switches auth to Basic (GRAFANA_USER also accepted) |
LOKI_TENANT_ID | No | Tenant for multi-tenant self-hosted Loki, sent as X-Scope-OrgID |
Priority
Configuration is resolved highest to lowest:
- Overrides passed to
createLokiDrain() runtimeConfig.evlog.loki(Nitro)runtimeConfig.loki(Nitro)- Environment variables
Options
| Option | Type | Default | Description |
|---|---|---|---|
endpoint | string | — | Base URL, without /loki/api/v1/push |
apiKey | string | — | API token |
user | string | — | Grafana Cloud instance ID (enables Basic auth) |
tenantId | string | — | X-Scope-OrgID for multi-tenant Loki |
labelFields | string[] | ['service', 'environment', 'level'] | Event fields promoted to Loki labels |
labels | Record<string, string> | — | Static labels merged into every stream |
timeout | number | 5000 | Request timeout in ms |
retries | number | 2 | Retry attempts on transient failures |
Deployment
Loki runs either way, and the adapter is the same in both cases — only authentication differs.
Self-hosted
A single-tenant instance needs nothing but the endpoint:
createLokiDrain({ endpoint: 'http://localhost:3100' })
LOKI_ENDPOINT=http://localhost:3100
For a multi-tenant deployment, name the tenant — it is sent as X-Scope-OrgID:
createLokiDrain({
endpoint: 'http://loki.internal:3100',
tenantId: 'team-checkout',
})
LOKI_ENDPOINT=http://loki.internal:3100
LOKI_TENANT_ID=team-checkout
If your instance sits behind an authenticating proxy, apiKey alone is sent as
Authorization: Bearer.
Grafana Cloud
Grafana Cloud authenticates with your instance ID plus an access policy token, sent together as HTTP Basic:
createLokiDrain({
endpoint: 'https://logs-prod-eu-west-0.grafana.net',
user: '123456',
apiKey: process.env.GRAFANA_API_KEY,
})
LOKI_ENDPOINT=https://logs-prod-eu-west-0.grafana.net
LOKI_USER=123456
LOKI_API_KEY=glc_xxx
user is the numeric instance ID of the Loki datasource — find it under
Connections → Data sources → Loki in your Grafana Cloud stack. It is not your
account email. Using the wrong value is the usual cause of a 401.Which auth applies
user | apiKey | tenantId | Header sent |
|---|---|---|---|
| ✓ | ✓ | Authorization: Basic base64(user:apiKey) | |
| ✓ | Authorization: Bearer <apiKey> | ||
| ✓ | X-Scope-OrgID: <tenantId> | ||
| none — unauthenticated instance |
tenantId is independent and can be combined with either auth mode.
Labels and cardinality
requestId or userId will create millions of streams and degrade or break your instance.Add a label only for values you filter on and that have a bounded set:
createLokiDrain({
// `region` has a handful of values — safe
labelFields: ['service', 'environment', 'level', 'region'],
// Static labels for everything in this deployment
labels: { cluster: 'prod-eu' },
})
Everything else stays in the JSON line and is fully queryable — see below.
Querying in Grafana
Filter by label, then reach into the wide event with | json:
{service="checkout", environment="production"}
| json
| status >= 500
# Slow requests on one route
{service="checkout"} | json | path="/api/orders" | duration_ms > 1000
# One request end to end
{service="checkout"} | json | requestId="4a8ff3a8-..."
# Error rate per service
sum by (service) (rate({environment="production", level="error"}[5m]))
Batching
Pair the drain with a pipeline to push in batches rather than per request:
import { createDrainPipeline } from 'evlog/pipeline'
import { createLokiDrain } from 'evlog/loki'
import type { DrainContext } from 'evlog'
const pipeline = createDrainPipeline<DrainContext>({
batch: { size: 100, intervalMs: 5000 },
})
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('evlog:drain', pipeline(createLokiDrain()))
})
Events sharing a label set are grouped into a single Loki stream, and entries are sorted by timestamp — Loki rejects out-of-order pushes within a stream.
Verify it locally
Spin up Loki in Docker and push a real event — no cloud account needed:
docker compose -f packages/evlog/test/e2e/docker-compose.yml up -d
LOKI_ENDPOINT=http://localhost:3100 pnpm run test:e2e
docker compose -f packages/evlog/test/e2e/docker-compose.yml down -v
The suite is a round-trip: it pushes events, queries them back through
Loki's range API, and asserts the label set, the JSON log line and the
timestamp survived. Without LOKI_ENDPOINT it skips itself with a visible
label rather than passing silently.
To eyeball the result instead, point any Grafana at http://localhost:3100
and run {service="evlog-e2e"}.
Troubleshooting
Missing endpoint — LOKI_ENDPOINT is unset and no endpoint was passed. The drain logs [evlog/loki] Missing endpoint once and then does nothing, so requests are never blocked or failed.
Loki API error: 401 — On Grafana Cloud, check that user is the numeric instance ID of the Loki datasource, not your account email.
Loki API error: 400 mentioning out-of-order entries — Older Loki versions reject entries older than the most recent one in a stream. evlog sorts within each push; if you still hit this, enable unordered_writes in Loki or reduce the batch interval.
Nothing appears in Grafana — Confirm the label set you are querying. Run {service=~".+"} first to see which streams arrived.
Direct API usage
Bypass the drain to push events yourself:
import { sendBatchToLoki, sendToLoki } from 'evlog/loki'
await sendToLoki(event, { endpoint: 'http://localhost:3100' })
await sendBatchToLoki(events, { endpoint: 'http://localhost:3100' })
buildLokiPayload(), toLokiLabels(), and resolveLokiPushUrl() are also exported for custom transports.
Next steps
- Sampling — control log volume before it reaches Loki
- Enrichers — add derived fields to every event
- Pipeline — batching, retries, and fan-out
- Adapters Overview — all available destinations