Server logs
Send log lines from Fly, Vercel, pino or any HTTP shipper and search them next to your analytics. Secrets and personal data are removed before a line is stored.
Send your server's log lines to Little Friend and search them next to your analytics. A 503 from your API shows up in the same place as the visits it broke, and a line from a journey session links to that session's replay.
Logs are off until an owner or editor turns them on in the dashboard under Settings, Logs. Lines are kept 14 days by default. You can choose 1 to 30 days per project.
Send lines
Every way in uses a secret server key, lfs_..., from Settings, Keys. Lines go to https://in.littlefriend.io/v1/logs.
From Fly.io
Fly's log shipper reads the logs of every app in your organization and posts them over HTTP. Deploy it as its own app with its usual ORG and ACCESS_TOKEN secrets, then point its HTTP sink at Little Friend:
fly secrets set -a <your log shipper app> \
HTTP_URL=https://in.littlefriend.io/v1/logs \
HTTP_TOKEN=lfs_YOUR_SERVER_KEYEach line's source is the Fly app's name. An app that logs JSON, with pino for example, is read field by field.
From Vercel
If the site already has a Vercel log drain, there is nothing to add. With logs on, the drain's function and edge lines are kept too, with their level, request id, deployment and status. Requests for static assets, like scripts, styles, images and fonts, are skipped when they carry no message, so they never count toward your daily lines.
From Node.js with pino
pnpm add @littlefriend/node pinoimport pino from 'pino';
const logger = pino({
transport: {
target: '@littlefriend/node/pino',
options: { key: process.env.LF_SERVER_KEY, source: 'api' },
},
});
logger.error({ status: 503, path: '/api/media' }, 'upload failed');The transport runs in a worker thread. It batches lines, gzips them and retries, and it never throws into your app. Fastify takes the same transport option.
From anything else
Post NDJSON, a JSON array or one JSON object, plain or gzipped. Vector, Logtail-style shippers and most loggers' JSON output work as they are. A line that is not JSON is kept as text.
curl https://in.littlefriend.io/v1/logs \
-H "Authorization: Bearer lfs_YOUR_SERVER_KEY" \
-H "Content-Type: application/x-ndjson" \
--data-binary $'{"level":"error","msg":"upload failed","status":503,"service":"api"}\n{"level":"info","msg":"retry ok"}'Little Friend reads the field names loggers already use: time or timestamp, msg or message, level, service or source, status, method, path and requestId. Other fields are kept as attributes. A line must be from the last 24 hours.
Link a line to its session
In journey mode, forward the browser's session id in an x-lf-session request header. A request logger that logs request headers, like pino-http, links every line of that request.
import { getSession } from '@littlefriend/tracker';
fetch('/api/orders', { headers: { 'x-lf-session': getSession() ?? '' } });Or log the id as a field:
logger.info({ lf_session: sessionId }, 'checkout failed');In aggregate mode there is no session id, so lines link to nothing. Erasing a person's sessions also erases the log lines linked to them.
What is removed
The collector removes secrets and personal data from every line before it is stored. Authorization and cookie headers, bearer and basic credentials, JWTs, passwords, tokens and secrets, emails, card numbers, query strings, IP addresses, full user agents and location headers never reach your log search. Request queries and bodies that a request logger copies are dropped, and so are fields named for an address, a location or a user agent. The same names are found in text, so a printed object or a logfmt line keeps its labels and loses those values. In aggregate mode, no session id is kept on any line, not even in the message.
| Your server logs | Little Friend stores |
|---|---|
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.c2lnbmF0dXJl | Authorization: [redacted] |
login failed for jane@example.com from 203.0.113.9 | login failed for [email] from [ip] |
charge declined card=4242 4242 4242 4242 | charge declined card=[card] |
GET /reset?token=8f2c1e9a&email=jane%40example.com 400 | GET /reset?[query] 400 |
db connect password=hunter2 host=db.internal | db connect password=[redacted] host=db.internal |
signup { email: 'jane@example.com', newPassword: 'hunter2', 'x-vercel-ip-city': 'London' } | signup { email: '[email]', newPassword: '[redacted]', 'x-vercel-ip-city': '[redacted]' } |
Redaction is a safety net. Avoid logging secrets in the first place.
Search
The Logs page searches the text of each line and filters by level, status (such as 503 or 5xx), source, path, session and request id, over any range up to 30 days. Open a line to see its fields, the other lines of its request and its session's replay.
The same search works from a terminal with the CLI, where logs tail follows new lines as they arrive:
littlefriend logs search "upload failed" --since 1h --status 5xx --source api
littlefriend logs tail --level errorAI assistants connected over MCP can search too. Ask "show 5xx errors from the API in the last hour".
Limits
- 1,000,000 stored lines per project per UTC day. Past that the collector answers 429 until midnight UTC, and the Logs page shows how many lines were refused.
- Messages are cut at 16 KiB, and at most 32 extra fields are kept per line.
- Turning logs off stops new lines. Lines already kept are removed as they pass the retention window.