Langfuse-compatible ingest
If you're using the Langfuse SDK (Python langfuse, JS langfuse-js, LangChain integration, OpenAI proxy) ActivityLog can be a drop-in destination. The endpoint matches Langfuse's public ingest contract verbatim, so the only change in your code is the LANGFUSE_HOST environment variable.
Endpoint
POST https://api.activitylog.com/api/public/ingestion
The path is intentional — Langfuse SDKs hard-code /api/public/ingestion against their host base URL.
Configure your SDK
Set three environment variables (or pass them to the SDK constructor):
export LANGFUSE_HOST=https://api.activitylog.com
export LANGFUSE_PUBLIC_KEY=<the system token's public id>
export LANGFUSE_SECRET_KEY=al_REPLACE_WITH_YOUR_TOKEN
That's the full configuration change. The SDK continues to call langfuse.trace(), .span(), .generation(), .score() exactly as before.
Where do PUBLIC_KEY and SECRET_KEY come from?
Both are values of one ActivityLog system token:
LANGFUSE_PUBLIC_KEY— the token's public id (visible on the Systems detail page next to the token row).LANGFUSE_SECRET_KEY— the token's bearer value (theal_…string you copied when minting).
ActivityLog accepts the Langfuse Basic-auth format (Authorization: Basic base64(publicKey:secretKey)) and the bearer format interchangeably — you can also set LANGFUSE_AUTH_HEADER=bearer if your SDK supports it.

Python example
from langfuse import Langfuse
langfuse = Langfuse(
host="https://api.activitylog.com",
public_key="<token public id>",
secret_key="al_REPLACE_WITH_YOUR_TOKEN",
)
trace = langfuse.trace(name="user-question", user_id="usr_42")
generation = trace.generation(
name="gpt-4o-summarize",
model="gpt-4o",
input=[{"role": "user", "content": "Summarize this article..."}],
)
# ... call the model ...
generation.end(
output="Here is the summary...",
usage={"input": 1024, "output": 256, "total": 1280},
)
JS example
import { Langfuse } from 'langfuse';
const langfuse = new Langfuse({
host: 'https://api.activitylog.com',
publicKey: '<token public id>',
secretKey: 'al_REPLACE_WITH_YOUR_TOKEN',
});
const trace = langfuse.trace({ name: 'user-question', userId: 'usr_42' });
const generation = trace.generation({
name: 'gpt-4o-summarize',
model: 'gpt-4o',
input: [{ role: 'user', content: 'Summarize this article...' }],
});
// ... call the model ...
generation.end({
output: 'Here is the summary...',
usage: { input: 1024, output: 256, total: 1280 },
});
How Langfuse events map to Message rows
Each Langfuse event type translates to a Message row with the equivalent shape:
| Langfuse event | Message shape |
|---|---|
trace-create |
type: "trace", traceId set, root-of-tree |
trace-update |
Updates the trace row's metadata (tags, user_id) |
span-create |
type: "span", parented via body.parentObservationId |
span-update |
Updates endTimestamp, status |
event-create |
Child message of parentObservationId |
generation-create |
type: "ai.generation" — promotes model / usage.input / usage.output / cost to first-party columns (per the gen_ai.* rule) |
generation-update |
Updates the generation row (usage finalization after streaming) |
score-create |
New MessageAnnotation row attached to the trace/observation |
score-update |
Updates the annotation |
Full mapping in ../Telemetry-Ingest.md § 4.4.
Cost analytics
Because generation-create events go through the same gen_ai.* promotion logic as OTLP, you can query LLM cost the same way regardless of which client emitted the data:
curl "https://api.activitylog.com/api/v1/messages/aggregate?type=ai.generation&groupBy=model&metric=sum(costMicrocents)&from=2026-05-01" \
-H "Authorization: Bearer YOUR_JWT_HERE"
(Full query reference in 60-query-api.md.)
What's supported
| Langfuse feature | Status |
|---|---|
Tracing API (trace, span, event, generation) |
Supported. |
Scoring API (score) |
Supported. Lands as MessageAnnotation rows. |
| Streamed generation updates | Supported. Maps to duration-message open→close lifecycle. |
| Tags, user IDs, session IDs | Supported. Lands as metadata. |
Datasets (langfuse.datasets) |
Not supported. Evaluation/dataset workflows are Langfuse-platform features; if you need them, run Langfuse Cloud + ActivityLog side-by-side. |
| Prompt-management UI (Langfuse-side templating) | Not supported. Same reason. |
Fan-out (Langfuse + ActivityLog at the same time)
A common evaluation pattern is to write to both Langfuse Cloud and ActivityLog in parallel. Two ways:
- Two SDK instances in your app. Both emit on every operation. Simple, but doubles SDK overhead.
- OpenTelemetry Collector fan-out. Your app emits OTLP; the collector sends a copy to ActivityLog (
/otlp/v1/traces) and another copy to a secondotlphttpexporter pointed at your other backend. One emit, two destinations. Recommended. See51-ingest-otlp.mdfor collector config.
What's next
| Goal | Doc |
|---|---|
The exact Langfuse→Message mapping |
../Telemetry-Ingest.md § 4 |
| Query LLM cost back | 60-query-api.md |
| Native ingest (if you don't want a Langfuse-shaped contract) | 50-ingest-native.md |
Troubleshooting
401 Unauthorized despite the SDK reporting success setting the keys.
Most Langfuse SDKs accept username:password Basic auth even on broken creds and only fail on the first POST. Confirm by hitting the endpoint with curl --user "$PUBLIC:$SECRET" https://api.activitylog.com/api/public/ingestion — should return a JSON ingestion-event-required error, not an auth error.
My traces appear in ActivityLog but my scores don't.
The score-create event lands as a MessageAnnotation, not a Message. The Messages view doesn't show annotations directly today; query via the annotations endpoint or wait for the dedicated annotation surface (planned).