Self-hosted telemetry server: logs and traces in over OTLP, an explore UI out, and a mail or a Google Chat message to whoever subscribed when a service starts logging trouble. One container, one PostgreSQL, no agent to install.
Built for the situation where a company runs several applications, each deployed separately for several clients, and a team member may only see the telemetry of the applications they were granted.
- Takes OTLP as it comes — logs and traces, gRPC on
:4317and HTTP on:4318, from any OpenTelemetry SDK or collector. A Service authenticates with its own API key, and that key is what decides whose telemetry this is — never what the payload claims about itself (ADR 0006). - Explore without a query language. Narrow by application, namespace, environment, service, severity, message or any attribute; the volume chart above the list answers "since when" before you have finished reading the first page, and marks the deployments that happened in the window. Follow keeps the newest at the top.
- Traces that stay attached to their logs. A waterfall per trace, every span with its attributes and events, and one click from a span to the log records written inside it — or from a log to the trace it belongs to.
- An unattended watch. Bugler polls what it stored, groups the same kind of trouble into an Episode rather than mailing every line, closes it after a quiet window, and mails or posts to Google Chat. Episodes can be acknowledged and solved, so a team can see who has it.
- Access granted per application. Local accounts, no identity provider required; the first account created becomes the administrator. A member sees exactly the applications they were granted, and nothing tells them the rest exist.
- Retention, per service, in days — separately for logs and for traces, with a storage ledger that says what each service costs today and what it will settle at.
- Runs as one container. No sidecar, no message broker, no object storage: a .NET 10 modular monolith serving the UI, the REST API and both OTLP surfaces, over a single PostgreSQL.
Metrics have no receiver yet; they are planned for a later phase.
A selected record shows what the sender attached to it — its own attributes, the resource that declared it, the scope that wrote it — and, when it was written inside a span, the way into the trace it belongs to.
One line per trace, with the slow ones marked in passing.
The waterfall, the failing span, its attributes and the exception event exactly as it arrived — and
View correlated logs, which is the same journey as the previous screen in the other direction.
An Episode is one kind of trouble in one Service — not one log line. It records what opened it, at which release, how loud it has been since, and who acknowledged it.
Applications and their Services, retention for each, alerting sensitivity and quiet windows, health checks, and the API keys — issued once and shown once.
What each Service's telemetry costs today, how fast it is growing, and what it will settle at once its retention starts throwing the oldest away. Estimates are marked as estimates.
Who may read what, as a matrix. Administrators are never scoped; everybody else is.
docker compose up --build -dThis raises everything Bugler talks to — a PostgreSQL, and a mailpit that swallows every message so nothing reaches a real inbox. It is meant for a laptop. On a server both of those already exist and belong to somebody else: see DEPLOYMENT.md.
Open http://localhost:8080 — the first account created becomes the server administrator. Register an application and a service in Admin, issue an API key, and point your services at the server:
OTEL_EXPORTER_OTLP_ENDPOINT=http://your-server:4317
OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer blgr_..."See Sending telemetry for the details, and for the two mistakes that make an exporter drop everything without saying a word.
Nothing to send yet? tools/Bugler.SampleSource simulates a small
e-shop — traces with correlated logs, a steady trickle of failures and a rarer one — which is what
fills the screens above.
- Single-process modular monolith (.NET 10, ASP.NET Core): OTLP ingest (gRPC :4317, HTTP :4318), REST API, and the frontend served from one container.
- PostgreSQL as the only backing store (telemetry + catalog + users).
- React + TypeScript frontend (Bun toolchain, shadcn/ui, TanStack Router/Query).
- Distributed via Docker + docker-compose.
The domain hierarchy is Application → Service → Tenant:
- An Application is a product; user access is granted per application.
- A Service is one registered sender — one role of one deployment, identified by its namespace, environment and name (
demo/prod · backend). It owns its API keys and retention, and its identity is what the key proves, never what the telemetry claims about itself (ADR 0006). - A Tenant is a customer inside a multi-tenant Service, visible only as a filter attribute in telemetry.
The codebase is split into five bounded contexts — Ingestion, Exploration, Alerting, Registry, Access — described in CONTEXT-MAP.md, each with its own glossary (src/Bugler.<Context>/CONTEXT.md). Architectural decisions are recorded as ADRs in docs/adr.
Bugler mails two things: alerts to whoever subscribed, and password-reset links. Without SMTP both stay quietly off — Bugler runs fine, but alerts reach inboxes never and passwords cannot be reset by link.
Configure it while running, in Admin → Server: server (hostname or IP), port, security mode, credentials if the relay wants any, and the From address. Saving applies to the very next mail — no restart. The same screen sends a test message to your own account address and reports what the SMTP server actually said; use it, because a relay that refuses Bugler otherwise surfaces only in the container log.
A bare internal relay is a first-class citizen: an IP for the server, security None, credentials
empty. The security modes:
| Mode | Meaning |
|---|---|
Automatic |
STARTTLS when the server offers it, plaintext when it does not — the default |
None |
plaintext on purpose, even if the server advertises STARTTLS |
StartTls |
STARTTLS or the send fails — refuses to downgrade |
ImplicitTls |
TLS from the first byte — the dedicated-port style, usually 465 |
Settings saved on the screen live in the database and win whole — never field by field — over
the Mail:Smtp configuration section from the first save until the screen's Reset to server
configuration; the screen always says which side is live
(ADR 0014). A deployment
that keeps SMTP in the environment keeps working unchanged; these matter only while nothing was
ever saved in the UI:
Mail__Smtp__Host: "smtp.example.com" # empty = mail disabled
Mail__Smtp__Port: "587"
Mail__Smtp__Security: "Automatic" # Automatic | None | StartTls | ImplicitTls
Mail__Smtp__Username: "" # empty = no authentication
Mail__Smtp__Password: ""
Mail__Smtp__From: "bugler@example.com"The SMTP password is write-only: once saved it is never shown again, only replaced or removed.
The docker compose of the previous section ships a mailpit that swallows everything Bugler
sends — read it at http://localhost:8025.
Bugler is a plain OTLP endpoint. Anything that speaks the protocol can export to it — an
OpenTelemetry SDK in any language, a logging-library sink such as Serilog.Sinks.OpenTelemetry
or the OpenTelemetry Logback appender, or a Collector forwarding on your behalf. Bugler is not
involved in the choice.
| OTLP/gRPC | :4317 — one address for every signal |
| OTLP/HTTP | :4318 — POST /v1/logs, POST /v1/traces |
| Signals | logs and traces; metrics are not implemented yet |
| Authentication | Authorization: Bearer blgr_… on every export |
| Body | protobuf only — JSON-encoded OTLP is refused with 415 |
Those are the ports the server itself listens on, as plain HTTP. Behind a reverse proxy the address
senders are given is usually the hostname with no port at all, the proxy routing /v1/logs,
/v1/traces and the gRPC service paths to them — see
DEPLOYMENT.md.
Register an Application and a Service in Admin and issue that Service an API key. The key is
the sender's identity: service.name and the rest of the resource attributes are stored and shown,
but never decide which Service the data belongs to (ADR 0006).
One key per deployed role — reusing a key across two deployments merges their telemetry.
Every OpenTelemetry SDK reads these variables, so they work regardless of language or framework:
OTEL_EXPORTER_OTLP_ENDPOINT=http://your-server:4317
OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer blgr_..."
# for OTLP/HTTP instead of gRPC:
# OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
# OTEL_EXPORTER_OTLP_ENDPOINT=http://your-server:4318Configuring an exporter in code works equally well — but read the next two sections first, because both failure modes below are silent. An exporter that cannot deliver drops the batch and lets the application run on; nothing appears in Bugler and nothing appears in your logs.
Exporters disagree about what an endpoint setting means, and the disagreement is invisible until telemetry goes missing.
| Exporter | Give it |
|---|---|
OTEL_EXPORTER_OTLP_ENDPOINT, any SDK |
base — the SDK appends /v1/logs, /v1/traces itself |
OpenTelemetry .NET, OtlpExporterOptions.Endpoint assigned in code |
full per-signal URL — the SDK appends nothing |
Serilog.Sinks.OpenTelemetry 4.x, options.Endpoint |
base — the sink derives LogsEndpoint/TracesEndpoint |
Getting it wrong lands you on / or on /v1/logs/v1/logs; both answer 404/405 and the batch
is gone.
With gRPC the question does not arise — signals are routed by service name, so
http://your-server:4317 is correct for every exporter. Prefer gRPC unless something forces HTTP.
Applications often export logs through one library and traces through another — a Serilog sink
beside an OTel SDK, for instance. If both read the same endpoint setting, a full HTTP path cannot
satisfy them: the exporter that appends nothing will send traces and metrics to /v1/logs.
Bugler decodes those as a malformed log batch and rejects the whole request with 400, discarding
the genuine log records travelling in it. Give each exporter its own setting, or use gRPC.
Check the status Bugler returned before suspecting Bugler:
| Status | Meaning |
|---|---|
401 / gRPC UNAUTHENTICATED |
missing key, or no Service matches it |
404 / 405 |
wrong path — only POST /v1/logs and POST /v1/traces exist |
415 |
Content-Type is not application/x-protobuf |
400 |
body is not a decodable OTLP payload for that signal |
503 / gRPC UNAVAILABLE |
ingest buffer full; the exporter should retry |
gRPC UNIMPLEMENTED |
metrics — Bugler has no metrics receiver yet |
Exporters hide these by design, so turn their own diagnostics on: Serilog.Debugging.SelfLog.Enable(…)
for a Serilog sink, the equivalent self-diagnostics channel for your SDK. From Bugler's side, running
it with Logging__LogLevel__Microsoft.AspNetCore=Information logs every request with its status code,
which is usually the fastest way to see what a silent exporter is really sending.
A quick way to prove the server, the key and the network before blaming your application:
curl -i -X POST http://your-server:4318/v1/logs \
-H "Content-Type: application/x-protobuf" \
-H "Authorization: Bearer blgr_..." --data-binary ''An empty body is a valid, empty OTLP request: 200 means the path and the key are good.
The REST API on :8080 is the UI's own, and it authenticates with the Session cookie — not with a
Service API key, which is for exports only. A script that signs in and then changes something has to
send one header of its own:
curl -i -X POST https://bugler.example.com/api/auth/login \
-H "Content-Type: application/json" \
-H "Bugler-Request: 1" \
-d '{"email":"you@example.com","password":"…"}'Bugler-Request is required on every method that is not a read, its value is never looked at, and
without it the answer is 403. It is what keeps a page on another origin from spending your Session:
such a page can make your browser send the cookie, but it cannot make it send a header
(ADR 0025). Reads need nothing.
Prerequisites: .NET 10 SDK, Bun, Docker.
docker compose up -d # local PostgreSQL
dotnet run --project src/Bugler.Host
cd frontend && bun dev # frontend dev server with HMRThe Host listens on :8080 (API/UI), :4317 (OTLP/gRPC), and :4318 (OTLP/HTTP). Database schema migrates automatically at startup. Each process authenticates its exports with its Service API key as a bearer token, e.g. OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer blgr_...".
| Layer | Where | Run |
|---|---|---|
| Unit (backend) | tests/Bugler.<Context>.Tests |
dotnet test |
| Architecture | tests/Bugler.ArchitectureTests (ArchUnitNET) + frontend/.dependency-cruiser.cjs |
dotnet test / bun run arch |
| Integration (real PostgreSQL via Testcontainers) | tests/Bugler.IntegrationTests |
dotnet test (needs Docker) |
| Unit (frontend) | frontend/**/*.test.tsx |
bun test |
| E2E (Playwright) | e2e/tests |
cd e2e && bun run test (needs docker compose up -d postgres) |
Architecture tests enforce the context boundaries described in CONTEXT-MAP.md; a dependency that crosses a context boundary outside its Contracts namespace fails the build.
The whole path is implemented and runs: OTLP ingest of logs and traces, the explore UI, applications, services, API keys and retention, local accounts with per-application grants, and the alerting watch with its mail and Google Chat notifications.
Metrics have no receiver yet, and the version says what it says: Bugler is still on 0.x, because
it has not been run in enough places for 1.0 to be an honest number. Until it is, a minor version
may move configuration or the database schema underneath you; read the release notes before
upgrading.
The current version is whichever is newest under Releases — deliberately not repeated here, since a number written into the repository is made stale by the very commit that writes it.
Bugler is written and maintained by one person in their own time. Issues are read, and none of the usual promises follow from that: there is no response time, no service level, and no guarantee that a request will be built. Saying so plainly seems better than letting anyone find out by waiting.
Bug reports and questions are welcome as issues. A pull request that fixes a typo, the documentation, or a contained bug can arrive without asking; anything that changes behaviour wants an issue first, so that the shape can be agreed before you spend an afternoon on it. CONTRIBUTING.md has the detail, including what the build enforces about context boundaries and translated strings.
Found a vulnerability? Not in an issue, please — SECURITY.md says where.
Bugler is licensed under the Apache License 2.0. You may run it, modify it and distribute it, including commercially, provided you keep the copyright notices and state what you changed. It comes with no warranty of any kind. Third-party material Bugler carries — the OpenTelemetry protocol definitions and the IBM Plex typefaces — is listed in NOTICE.
The licence covers the code, not the name. "Bugler" and the Bugler logo are not licensed under Apache 2.0 and remain the author's. A fork is welcome to exist, and must call itself something else — so that nobody installing "Bugler" has to wonder whose it is, and so that its bugs land in its own tracker.







