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 (the al_… 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.

Token detail page showing the public id and the (hidden) bearer value

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:

  1. Two SDK instances in your app. Both emit on every operation. Simple, but doubles SDK overhead.
  2. OpenTelemetry Collector fan-out. Your app emits OTLP; the collector sends a copy to ActivityLog (/otlp/v1/traces) and another copy to a second otlphttp exporter pointed at your other backend. One emit, two destinations. Recommended. See 51-ingest-otlp.md for 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).