# 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):

```bash
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](screenshots/portal-systems-token-detail-publicid.png)

## Python example

```python
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

```javascript
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`](../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:

```bash
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`](./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`](./51-ingest-otlp.md) for collector config.

## What's next

| Goal | Doc |
|---|---|
| The exact Langfuse→`Message` mapping | [`../Telemetry-Ingest.md`](../Telemetry-Ingest.md) § 4 |
| Query LLM cost back | [`60-query-api.md`](./60-query-api.md) |
| Native ingest (if you don't want a Langfuse-shaped contract) | [`50-ingest-native.md`](./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).
