Skip to content

feat: add Langfuse Wasm FDW to the catalog - #622

Merged
burmecia merged 5 commits into
supabase:mainfrom
distanceqo:docs/langfuse-wasm-fdw
Aug 14, 2026
Merged

feat: add Langfuse Wasm FDW to the catalog#622
burmecia merged 5 commits into
supabase:mainfrom
distanceqo:docs/langfuse-wasm-fdw

Conversation

@distanceqo

Copy link
Copy Markdown
Contributor

Adds catalog documentation for a community Wasm wrapper for Langfuse, an open source LLM observability platform.

The wrapper exposes traces, observations, token usage, and cost as foreign tables, so LLM spend can be attributed to rows in a local users table in plain SQL:

with llm as (
  select user_id, count(*) as llm_calls, sum(total_cost) as cost_usd
  from langfuse.traces
  where timestamp >= '2026-08-01'::timestamp
  group by user_id
)
select u.email, coalesce(l.llm_calls, 0) as llm_calls, l.cost_usd
from auth.users u
left join llm l on l.user_id = u.id::text
order by l.cost_usd desc nulls last;

It targets the Langfuse Public API rather than the ClickHouse store behind it, which upstream documents as not a stable API contract and which Langfuse Cloud users cannot reach at all.

Changes

File Change
docs/catalog/langfuse.md New page, following the structure of gravatar.md
docs/catalog/index.md One row in the Official table, one in Community
docs/catalog/wasm/index.md Card entry
mkdocs.yaml Nav entry under Wasm

Verification

Installed and queried on a Supabase project against a live Langfuse Cloud project. Everything the docs claim was checked rather than assumed:

  • Vault-backed auth, cursor and page-number pagination, lazy page fetching
  • limit and equality pushdown; time bounds via fromTimestamp/toTimestamp (and fromStartTime/toStartTime on observations — the column and params differ per endpoint)
  • sum(total_cost) agrees at 0.15584 across traces and observations for the same data despite reaching the value through different response fields (totalCost vs calculatedTotalCost), which is why the docs list fallback chains
  • A three-way join against auth.users and a local table returns correct per-user attribution

The Limitations section records behaviour found during that testing which may be useful beyond this wrapper:

  • now() in a time filter is not pushed down — Postgres reports Wrappers: quals = [] and filters locally after fetching every page. Literal timestamps are pushed.
  • v2/observations reads a different store than observations. On a fresh cloud project, rows ingested through both /api/public/ingestion and the OTLP endpoint were readable via observations while v2/observations returned an empty result, though it still validated query parameters.

@burmecia

Copy link
Copy Markdown
Contributor

hey @distanceqo , thanks for the PR! As this repo is mainly for Supabase maintained FDWs and it is targeting customer on Supabase platform, would you mind move source code into this PR as well?

Reads trace and observation data from Langfuse, an open source LLM
observability platform, so LLM spend can be attributed to rows in a local users
table in SQL.

Targets the Langfuse Public API rather than the ClickHouse store behind it,
which upstream documents as an unstable contract and which Langfuse Cloud users
cannot reach at all.

Supports select on `traces` and `observations`, import foreign schema, limit and
equality pushdown, and time bounds via fromTimestamp/toTimestamp (or
fromStartTime/toStartTime on observations — the column and params differ per
endpoint). Credentials resolve from a Vault secret name, a secret id, or a
plaintext option.

Usage and cost arrive as objects keyed by metric name and are flattened into
scalar columns so aggregates need no JSON extraction. The fallback chains are
load-bearing: `observations` returns calculatedTotalCost with no totalCost key,
and promptTokens rather than usageDetails.input.

Verified end to end on Supabase against a live Langfuse Cloud project.
@distanceqo
distanceqo force-pushed the docs/langfuse-wasm-fdw branch from 6e2438d to 9cb376b Compare August 10, 2026 14:15
@distanceqo

Copy link
Copy Markdown
Contributor Author

Thanks for taking a look! Source code is now in this PR under wasm-wrappers/fdw/langfuse_fdw/, and the docs are updated to point at a Supabase-hosted release.

Moving into this repo meant a few changes over what I had originally published:

  • Retargeted from WIT v1 to v2, which also let me implement import_foreign_schema — so import foreign schema langfuse from server langfuse_server into langfuse now creates both tables.
  • Credentials resolve from a Vault secret name via get_vault_secret_by_name, falling back to a secret id, then a plaintext option. The name form saves users having to look a UUID back up.
  • Package renamed to supabase:langfuse-fdw, registered in the wasm-wrappers/fdw workspace, edition 2024.

cargo component build --release is clean, as are rustfmt and clippy against wasm32-unknown-unknown.

Two things I'd appreciate your call on:

  1. The checksum in docs/catalog/langfuse.md is a placeholder — I can't compute it until a wasm_langfuse_fdw_v0.1.0 release exists. Happy to push the real value once you cut one, or if you'd rather fill it in during release, feel free.

  2. v2/observations reads a different store than observations. On a freshly created cloud project, rows ingested through both /api/public/ingestion and the OTLP endpoint were readable via observations while v2/observations returned {"data":[],"meta":{}} — though it still validated query parameters, so the endpoint is live. The wrapper handles both pagination styles and the tables point at observations for now. Worth noting since upstream currently recommends v2.

For reference, this was verified end to end on Supabase against a live Langfuse Cloud project — auth out of Vault, both pagination styles, limit/equality/time pushdown, and a join against auth.users producing correct per-user attribution. sum(total_cost) agrees across traces and observations for the same data despite reaching the value through different response fields, which is what the fallback chains in the docs are for.

CI runs clippy with RUSTFLAGS="-D warnings", which promotes
uninlined_format_args to an error. Nine call sites used the positional form.
@distanceqo

Copy link
Copy Markdown
Contributor Author

Fixed the clippy failure — nine format! call sites were using the positional form, which RUSTFLAGS="-D warnings" promotes to an error via uninlined_format_args. My local run had missed it because I wasn't passing that flag.

Verified with the same commands the workflow uses:

cd wasm-wrappers/fdw
cargo component build --release --target wasm32-unknown-unknown -p langfuse_fdw
cargo fmt -p langfuse_fdw && git diff --quiet
RUSTFLAGS="-D warnings" cargo clippy -p langfuse_fdw --tests --no-deps

All clean. The new run needs your approval to start, since I'm a first-time contributor here.

One heads-up in case you hit it: running clippy across the whole wasm-wrappers/fdw workspace on a current toolchain also flags collapsible_if and collapsible_match in several existing wrappers (hubspot, cfd1, infura, orb, clerk). Those are pre-existing and untouched by this PR — just noting it so the output isn't mistaken for something I introduced.

@burmecia burmecia changed the title docs: add Langfuse Wasm FDW to the catalog feat: add Langfuse Wasm FDW to the catalog Aug 12, 2026
@burmecia

Copy link
Copy Markdown
Contributor

The checksum in docs/catalog/langfuse.md is a placeholder
No worries, we will handle it during release.

Also, can you add smoke test case for the Wasm FDW? Below are the related files:

Once the test case is in place, you can run cargo component build --release --target wasm32-unknown-unknown and cargo pgrx test --features "pg15 wasm_fdw" to run the smoke test.

Mock responses are shaped so a regression fails rather than silently passing:

- Both endpoints report page/totalPages instead of a cursor, and the server is
  configured with page_size 1 against a two-page traces mock, so the test only
  sees both rows if pagination is followed.
- The traces mock returns nothing for any userId other than user-alice, so a
  dropped equality pushdown fails the assertion instead of falling back to
  unfiltered rows.
- The two observation rows spell usage and cost differently — one nests them
  under usageDetails/costDetails, the other only has the flat promptTokens and
  calculated*Cost forms. Both must map to the same columns, so a missing
  fallback surfaces as a NULL.
@distanceqo

Copy link
Copy Markdown
Contributor Author

Smoke test added — wrappers/dockerfiles/wasm/server.py and wrappers/src/fdw/wasm_fdw/tests.rs. Thanks for pointing at those; and good to know the checksum is handled at release time.

I tried to shape the mock so a regression fails rather than quietly passing:

  • Pagination is exercised, not assumed. Both endpoints report page/totalPages rather than a cursor, and the server is created with page_size '1' against a two-page traces mock. The assertion expects [trace-1, trace-2], so stopping after the first response fails.
  • The pushdown assertion can actually fail. The traces mock returns an empty data for any userId other than user-alice. If the qual were dropped from the URL, the unfiltered rows would come back and assert!(filtered.is_empty()) would break.
  • Both field spellings are covered. obs-1 nests usage and cost under usageDetails/costDetails; obs-2 only carries the flat promptTokens / calculatedTotalCost forms. Both have to land in the same columns, so a missing fallback shows up as a NULL that filter_map drops and the length check catches.

I verified the mock endpoints directly with curl (pagination, the userId filter, and that each observation row really only has one of the two shapes). I couldn't run cargo pgrx test locally — no Postgres toolchain on this machine and pgrx init builds it from source — so the Rust side is checked for formatting only. If the smoke test needs an adjustment when it runs in CI, let me know and I'll fix it promptly.

@burmecia burmecia added the wasm label Aug 13, 2026
burmecia and others added 2 commits August 14, 2026 17:49
Previously page_size was only checked for being a valid integer; values
outside the documented 1-1000 range (e.g. negative or larger than 1000)
were silently accepted and passed straight through as the upstream
limit= query parameter.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@burmecia
burmecia merged commit 2f0959c into supabase:main Aug 14, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants