---
url: https://simpledatalogs.com/docs/integrations/heroku.md
description: >-
  Send Heroku app, router and platform logs to simpledatalogs with an HTTPS log
  drain, router lines as structured fields.
---

# Heroku

A Heroku HTTPS log drain posts every line your app, the router and the platform write. Each line becomes a log line.

## Set it up

Heroku drains can't send headers, so the key goes in the URL:

```sh
heroku drains:add "https://sdl:sdl_sec_…@edge.simpledatalogs.com/heroku/v1/logs" -a your-app
```

* The key is the basic-auth password; the username can be anything.
* Or put a public key last in the path: `https://edge.simpledatalogs.com/heroku/v1/logs/sdl_pub_…`. A secret key in the path is refused, because URL paths end up in proxy logs.
* Use a secret key in the basic-auth form, and a drain per app. `drain_token` tells drains apart.

Check it with `heroku drains -a your-app`; lines arrive within seconds.

## What's stored

| Field | From the Logplex frame |
|---|---|
| `message` | The line, masked like a text line; JSON and logfmt lines are read like [any text line](/api/log-lines) |
| `level` | The line's own level, else the syslog priority |
| `ts` | The line's own time, else the frame's |
| `event_id` | Derived from Heroku's frame id and the line's position, so a redelivered frame never counts twice |
| `props` | `source` (`app` or `heroku`), `dyno` (`web.1`, `router`), `process` (`web`), `drain_token`, and the line's own fields |

Router lines read as `GET /pricing 200`, with `method`, `path`, `status`, `service_ms`, `connect_ms`, `bytes`, `request_id`, `host`, `protocol` and `target_dyno` as props. `at=error` lines (H12, H13 …) are errors, with `code` and `desc`.

The client IP (`fwd`) is never stored.

## Keep the volume down

::: warning Every router line is an event
The router writes one line per request. A busy app sends far more router lines than app lines, and each counts against your monthly events.
:::

* Filter by `source` = `app` in Logs to hide platform lines.
* To skip router lines entirely, log requests from your app instead, and use a separate drain only where you need them.

## Responses

* `200 {"accepted":"<id>"}` once stored.
* Other refusals are [`/v1/ingest`'s](/api/responses#v1-ingest). Logplex buffers lines while ingest refuses them, and drops the oldest once its buffer is full.

## Next steps

* [Vercel](/integrations/vercel): the other hosting drain.
* [Fly.io](/integrations/fly) and [Cloudflare](/integrations/cloudflare): recipes on existing paths.
