Skip to content

ci: e2e black-box job — boot web+worker, verify ingest→query over HTTP - #60

Merged
killme2008 merged 17 commits into
mainfrom
ci-e2e-blackbox-http
Jul 9, 2026
Merged

ci: e2e black-box job — boot web+worker, verify ingest→query over HTTP#60
killme2008 merged 17 commits into
mainfrom
ci-e2e-blackbox-http

Conversation

@killme2008

Copy link
Copy Markdown
Contributor

What

Adds a new CI job e2e black-box (HTTP ingest -> query) to .github/workflows/ci.yml.

It is the only job that boots the real web (:3000) and worker (:3030) HTTP servers and drives the full public-API path end to end:

POST /api/public/ingestion (SDK batch) + /api/public/otel/v1/traces (OTLP)
  -> IngestionQueue -> worker -> GreptimeDB
  -> query back via GET /api/public/{traces,observations,scores,sessions}

Why

After the ClickHouse -> GreptimeDB cutover, the existing servertests job only exercises the DB/tRPC layer in-process and never starts the servers. The 31 HTTP *-api.servertest.ts (which fetch localhost:3000) and the __e2e__ suite therefore had no CI coverage — the whole ingest→query black-box path was running only locally against a live pnpm run dev stack.

How

Mirrors upstream's tests-web + tests-server-e2e recipe, adapted for the fork:

  • Infra: Postgres/Redis via services:; GreptimeDB + MinIO via docker run (custom start commands GitHub services: can't express), reusing the servertests job pattern.
  • Boot: pnpm run builddb:deploy (Postgres) + greptime:migratedb:seed:examplespnpm run start & (turbo starts web+worker) → wait on both health endpoints.
  • Tests (share one build+boot):
    1. Curated HTTP *-api.servertest.ts subset: ingestion / otel / traces / observations / scores-v2 / sessions (self-provision their project).
    2. test:e2e:server__e2e__/api.servertest.ts (ingest → poll GET /traces/{id} → assert worker eval jobExecution) + otel-tenant-isolation.
  • LANGFUSE_INGESTION_QUEUE_DELAY_MS=1 + LANGFUSE_INGESTION_WRITE_INTERVAL_MS=1 so the async pipeline drains fast enough for the waitForExpect polls.
  • Dumps app logs on failure for diagnosability.

Notes

  • The test list is a curated green subset that grows as more paths pass on GreptimeDB — matching this workflow's existing "re-enabled one at a time" philosophy. Not required in branch protection yet.
  • First run will reveal whether the selected HTTP tests are all green on GreptimeDB; any red file gets trimmed + tracked separately per the same philosophy.

Verification

  • YAML parses (4 jobs, 17 steps in the new job).
  • All 6 referenced *-api.servertest.ts files + the two __e2e__ files + the test:e2e:server script exist.
  • Full green requires the CI run itself (needs the live web+worker+GreptimeDB stack).

…query over HTTP

The existing `servertests` job only exercises the DB/tRPC layer in-process and
never starts the servers, so the full public-API path (ingest -> queue ->
worker -> GreptimeDB -> query) had no CI coverage after the ClickHouse->GreptimeDB
cutover.

Add `e2e-blackbox`, mirroring upstream's tests-web + tests-server-e2e recipe but
adapted for the fork (no ClickHouse; GreptimeDB + MinIO started via docker run,
reusing the servertests infra pattern). It builds web+worker, migrates Postgres
and GreptimeDB, seeds example data, boots `pnpm run start`, waits on both health
endpoints, then runs a curated green subset of HTTP `*-api.servertest.ts`
(ingestion/otel/traces/observations/scores/sessions) plus the `__e2e__` suite
(`test:e2e:server`). Ingestion queue/write intervals are pinned to 1ms so the
async pipeline drains fast enough for the waitForExpect polls.

The test list is intentionally a curated subset that grows as more paths turn
green on GreptimeDB, matching this workflow's existing philosophy.
Comment thread .github/workflows/ci.yml Fixed
…ntain permissions'

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR extends the existing CI workflow by adding a new black-box end-to-end job that boots the real web and worker HTTP servers and runs a curated subset of HTTP-based public API tests to validate the full ingest → queue → worker → GreptimeDB → query path.

Changes:

  • Adds workflow-level permissions configuration (currently restricted to contents: read).
  • Introduces a new e2e-blackbox job that provisions Postgres/Redis + GreptimeDB/MinIO, builds and starts the app, waits on health endpoints, and runs selected HTTP + __e2e__ suites.
  • Captures app logs to aid debugging on failures.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread .github/workflows/ci.yml
Comment thread .github/workflows/ci.yml
Comment thread .github/workflows/ci.yml
Comment thread .github/workflows/ci.yml
Address review feedback: the health-wait loops only `break` on success and
otherwise fall through silently, so a service that never comes up surfaces as a
misleading downstream error. Re-check once after the loop and exit with the
container logs, making the root cause obvious.
GreptimeDB advertises text columns over the MySQL wire as VARCHAR with
characterSet 33 (utf8mb3). mysql2's default reader mis-decodes that combination
and turns every 4-byte UTF-8 code point (emoji, rare CJK) into U+FFFD — silent
data corruption on read, even though the wire bytes are valid UTF-8.

Add a typeCast on the SQL pool that decodes text columns straight from the raw
buffer as UTF-8. Numeric / temporal / DECIMAL / BIGINT columns fall through to
mysql2 so the decimalNumbers / bigNumberStrings handling is preserved. Verified
against a live GreptimeDB v1.1.1: emoji round-trips intact and decimal/bigint
still come back as precision-preserving strings.

Also restore the full HTTP black-box test list in the e2e job and pin the trace
delete delay/concurrency so the DELETE-then-poll tests don't time out.
The observations UI column definitions baked the table alias into
`clickhouseSelect` as a double-quoted identifier (`o."user_id"`) and omitted
`queryPrefix`, unlike the traces mapping. Over GreptimeDB's MySQL wire this
broke the advanced-filter path two ways:

  1. The metadata EAV EXISTS fell back to correlating on the bare table name
     (`observations.project_id`) instead of the outer alias `o`, because
     `outerAlias = tablePrefix ?? table` and `tablePrefix` (queryPrefix) was
     undefined. That both 500s ("No field named observations.project_id") and
     is a tenant-isolation-relevant miscorrelation.
  2. `o."col"` parses as alias `o` followed by a string literal — GreptimeDB
     does not run in ANSI_QUOTES mode — yielding "No field named o".

Align the observations mapping with the traces mapping: bare `clickhouseSelect`
plus `queryPrefix`, leaving the computed obs-aggregate / tool / scores columns
(which carry ClickHouse expressions and route through their own remapping)
untouched. The translation now emits backtick-quoted, alias-qualified SQL.

Add a regression test asserting the metadata EAV correlates on the outer alias
and scalar predicates are backtick-quoted with no double-quoted identifiers.
…EXISTS

The public-API traces surface sends `score_categories` as a StringOptions filter
whose options are combined `name:string_value` strings (ClickHouse stored the
column as `concat(name, ':', string_value)` and matched with `hasAny`). The
GreptimeDB translation only special-cased the `categoryOptions` filter type, so a
StringOptions filter on `score_categories` fell through to a plain
`s.score_categories IN (...)` predicate — a dangling reference to a column/alias
that does not exist on the merged projection, which 500s ("No field named
s.score_categories").

Add ScoreCategoryMembershipFilter: parse the `name:string_value` options (split
on the first `:`) and emit a project-scoped, grain-correlated, soft-delete-aware
EXISTS over `scores` matching `(name, string_value)` pairs (NOT EXISTS for
`none of`). Route StringOptions on `score_categories` to it. Also make the
rollup-score field detection prefix-insensitive so the same routing works
whether the field arrives bare or as `s.<col>` (this also fixes the parallel
`scores_avg` NumberObject path).

Add a regression test asserting the categorical EXISTS shape and negation.
The public-API `environment` filter only constrained the trace row
(`t.environment`); the `observations_stats` / `score_stats` rollup CTEs
aggregated every observation/score for the trace regardless of environment. A
trace whose observations or scores span environments therefore attached all of
them, so `GET /traces?environment=X` returned observations/scores from other
environments (asserted by "should fetch ... filtered by environment" and "should
fetch traces with trace scores only" — both saw 2 where 1 was expected).

Push the environment predicate into both rollup CTEs: compile the environment
StringOptions once against each CTE's base table (`observations` / `scores`, no
alias) and AND it into the CTE WHERE (via `extraFilterSql` for the obs CTE, and
inline for the score CTE). Fresh uid() params avoid colliding with the
trace-level predicate; the LEFT JOIN still keeps environment-matching traces that
have zero in-environment observations/scores.
greptimeLatencyMs() derived latency via to_unixtime(), which GreptimeDB truncates
to whole seconds. A sub-second observation, or one whose span straddles a second
boundary, was therefore mis-measured by up to ~1s — e.g. a 1.5s observation could
read as 2.0s and be dropped by a `latency <= 1.9` filter. Because the error
depends on where the wall-clock timestamps fall within their seconds, the public
API latency-filter test was flaky (passing or failing by alignment), and latency
display/ordering carried the same imprecision.

start_time/end_time are stored at millisecond precision, and
CAST(<timestamp> AS BIGINT) yields epoch milliseconds directly, so subtract the
cast greatest/least bounds instead of round-tripping through to_unixtime. Verified
against a live GreptimeDB: a 0.5s span now measures 500ms (was 0), a 2.5s span
2500ms (was 2000), and the latency filter test passes deterministically.
… coalescing

The ingestion rebuild-coalescing guard compared the Redis watermark against
`job.data.timestamp.getTime()`. `job.data.timestamp` is typed as a Date, but
BullMQ round-trips job data through JSON in Redis, so a dequeued job carries it
back as an ISO string — and `String.prototype.getTime` does not exist, so the
guard threw `TypeError: getTime is not a function`.

The guard is only reached once a coalesce watermark exists, i.e. from the second
update onward for a given entity (the first update short-circuits on the null
watermark). So with LANGFUSE_INGESTION_COALESCE_REBUILDS enabled (the default),
every follow-up rebuild of an already-seen trace/observation/score threw and
retried forever: entity updates silently never merged, and reads kept returning
the first snapshot. This is what the public-API "merge metadata across multiple
trace updates" black-box test caught.

Coerce through `new Date(job.data.timestamp)` so the comparison works whether the
value arrives as a Date or an ISO string. Verified end-to-end against a live
web+worker+GreptimeDB stack: the second update now merges and the test passes
deterministically.
The e2e job disabled LANGFUSE_CACHE_PROMPT_ENABLED (copied from upstream's
tests-web tuning), but this job also runs the __e2e__ suite, whose
"Prompts endpoint > creates and returns a prompt" test asserts that fetching a
prompt populates its Redis cache key (`expected 0 to be 1` when caching is off).
Upstream only disables prompt caching for the plain server-test project, not for
test:e2e:server. Drop the override so prompt caching runs at its default
(enabled) and the __e2e__ prompt-cache assertion holds.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated 1 comment.

Comment thread .github/workflows/ci.yml
…r a deleted trace

deleteTracesFromGreptime only tombstones child observations/scores that are
already visible in the projection tables (it resolves child ids via
readProjectionIdsByTraceIds). A child that was still in-flight in raw_events
(written but not yet projected) when its parent trace was deleted — or one that
is ingested late, after the delete — therefore never receives its own tombstone.
A later rebuild or reconciliation replays its (tombstone-free) raw_events history
and resurrects it as an orphan projection under a trace that no longer exists.

raw_events has no trace_id column (the trace id lives inside the event body), so
children cannot be enumerated by trace id at delete time. Instead each
observation/score rebuild now checks whether its parent trace is deleted
(isParentTraceDeleted -> parseRawEventHistory(trace history).deleted) and rebuilds
soft-deleted when it is. Wired into both the live ingestion path (ingestionQueue)
and the reconciliation path (which is the healer for pre-existing orphans),
alongside the existing project-tombstone guard.

Cost: one extra tag-indexed raw_events read per observation/score rebuild.
Correctness-first; gate behind a flag or short-TTL positive cache if it shows up
on the ingestion hot path at scale.

Adds a unit test for the new firstTraceId helper.
The local media signed upload/download routes rejected any non-PUT/GET method
with 405 before any CORS handling. Browser SDK uploads (and <audio>/<video>
range reads) are cross-origin from the app to the Langfuse host and send an
OPTIONS preflight for PUT + custom headers; the preflight 405'd, so the browser
never issued the real request and the upload/download silently failed. Every
other public API route runs the shared cors middleware. Run it here too and
answer OPTIONS before the method guard.
processClickhouseTraceDelete deletes from GreptimeDB but logged "from Clickhouse"
on both the info and error paths, which misleads operators reading worker logs
into looking at the wrong backend. The function name is kept for upstream parity;
only the runtime log strings are corrected.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 17 out of 17 changed files in this pull request and generated 3 comments.

Comment thread .github/workflows/ci.yml
Comment thread worker/src/queues/ingestionQueue.ts Outdated
Comment thread packages/shared/src/server/greptime/deletion.ts
…tTraceDeleted

- Add pull-requests: read to the workflow permissions block. dorny/paths-filter
  reads the PR's changed-file list on pull_request events, and the least-privilege
  contents:read-only block can fail the `changes` gate on shallow/fork checkouts,
  blocking all downstream jobs. Everything else stays read-only.
- Cover isParentTraceDeleted in deletion.test.ts: empty traceId -> false (no
  query), latest tombstone -> true, live-only -> false, live-after-tombstone
  (re-create) -> false.

Both address Copilot review comments on #60.
… NaN

Same BullMQ JSON round-trip issue as the ingestion coalescing guard:
job.data.timestamp is typed Date but arrives as an ISO string, so
`Date.now() - job.data.timestamp` evaluated to NaN and the
langfuse.dlq_retry_delay histogram silently recorded NaN. Coerce through Date().

The other job.data.timestamp consumers were already safe (retry-handler and
evalService both wrap it in new Date(...)); only this one did raw arithmetic.
…tchTimestamp helper

The queue payload contract declares `timestamp: z.date()`, but BullMQ round-trips
job data through JSON in Redis, so a dequeued job carries `timestamp` back as an
ISO string. That mismatch has already bitten two consumers (ingestion coalescing
threw "getTime is not a function"; the DLQ delay metric computed NaN), and each
was patched with its own `new Date(...)` coercion.

Add a single `jobDispatchTimestamp(job): Date` helper next to the queue contracts
and route every consumer through it (ingestionQueue, dlqRetryService,
retry-handler, evalQueue), so the coercion lives in one documented place and a
future consumer can't reintroduce the same bug by trusting the declared type.
No contract change — `new Date()` accepts both a Date and an ISO string.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 22 out of 22 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (2)

web/src/pages/api/public/media/[mediaId]/upload.ts:44

  • This route now explicitly supports CORS preflight via OPTIONS, but the 405 response advertises only PUT as allowed. For correctness/clarity (and consistency with the explicit OPTIONS support), include OPTIONS in the Allow header.
  if (req.method !== "PUT") {
    res.setHeader("Allow", "PUT");
    res.status(405).end();
    return;

web/src/pages/api/public/media/[mediaId]/download.ts:29

  • This route now explicitly supports CORS preflight via OPTIONS, but the 405 response advertises only GET as allowed. For correctness/clarity (and consistency with the explicit OPTIONS support), include OPTIONS in the Allow header.
  if (req.method !== "GET") {
    res.setHeader("Allow", "GET");
    res.status(405).end();
    return;

Comment thread packages/shared/src/server/queues.ts
The helper exists precisely because a Redis-replayed job carries `timestamp` as
an ISO string despite the schema declaring `z.date()`, but its parameter type
still said `Date` — undermining the helper's intent and misleading call sites.
Widen the accepted type to `Date | string` (the Date constructor accepts both);
callers passing a `Job` whose `data.timestamp` is typed `Date` still satisfy it.
@killme2008
killme2008 merged commit 9f5f6f2 into main Jul 9, 2026
13 checks passed
@killme2008
killme2008 deleted the ci-e2e-blackbox-http branch July 9, 2026 11:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants