diff --git a/README.md b/README.md index 1c63344..264fad9 100644 --- a/README.md +++ b/README.md @@ -40,14 +40,15 @@ WALLET_PATH=~/repos/browser-wallet \ ./up.sh ``` -This runs through 7 stages: +This runs through 8 stages: 1. Checks prerequisites (Docker, Stellar CLI, Deno, Cargo) -2. Starts a local Stellar network via Docker -3. Generates accounts (admin, provider, treasury) and funds them via Friendbot -4. Builds and deploys contracts (channel-auth, privacy-channel) -5. Registers the provider on the channel-auth contract -6. Starts the provider platform (PostgreSQL, migrations, server) -7. Builds wallet extensions for Chrome and Brave with dev seeds +2. Starts Jaeger for distributed tracing (UI at http://localhost:16686) +3. Starts a local Stellar network via Docker +4. Generates accounts (admin, provider, treasury) and funds them via Friendbot +5. Builds and deploys contracts (channel-auth, privacy-channel) +6. Registers the provider on the channel-auth contract +7. Starts the provider platform (PostgreSQL, migrations, server) +8. Builds wallet extensions for Chrome and Brave with dev seeds After it finishes, load `browser-wallet/dist/chrome/` or `dist/brave/` as unpacked extensions. @@ -71,7 +72,15 @@ Rebuilds the Chrome and Brave wallet extensions without restarting the network o cd e2e && deno task e2e ``` -Runs the full 8-step E2E test (fund, auth, deposit, receive, send, withdraw) against the local stack. +Runs the full E2E test (fund, auth, deposit, receive, send, withdraw) against the local stack. Traces are exported to Jaeger automatically. + +```bash +# Verify traces were captured +deno task verify-otel + +# Open Jaeger UI to inspect traces +open http://localhost:16686 +``` ## E2E in CI @@ -90,3 +99,4 @@ See [RELEASES.md](RELEASES.md) for the versioning strategy and release workflows - **Friendbot timeout**: The local Stellar node can take a few minutes on first start. Re-run `./up.sh`, it will pick up where it left off. - **Provider connection fails**: Check `provider.log` in this directory for errors. - **Contract deployment fails**: Make sure the local Stellar container is running (`docker ps`). +- **No traces in Jaeger**: Check `jaeger.log` and ensure `OTEL_DENO=true` is set (automatic with `deno task e2e`). diff --git a/down.sh b/down.sh index 1385f1c..818aac7 100755 --- a/down.sh +++ b/down.sh @@ -44,6 +44,12 @@ if [ "$killed" = false ]; then done fi +# --- Stop Jaeger --- +if docker ps -a --format '{{.Names}}' | grep -q "^jaeger-local$"; then + docker rm -f jaeger-local >/dev/null 2>&1 + info "Stopped Jaeger" +fi + # --- Stop PostgreSQL --- if [ -f "$PROVIDER_PLATFORM_PATH/docker-compose.yml" ]; then info "Stopping PostgreSQL..." @@ -70,6 +76,7 @@ for f in \ "$WALLET_PATH/.env.seed.local" \ "$WALLET_PATH/.env.seed.local.brave" \ "$SCRIPT_DIR/provider.log" \ + "$SCRIPT_DIR/jaeger.log" \ ; do if [ -f "$f" ]; then rm "$f" diff --git a/e2e/.gitignore b/e2e/.gitignore index 7c012f3..9f03b97 100644 --- a/e2e/.gitignore +++ b/e2e/.gitignore @@ -1,2 +1,3 @@ node_modules/ wasms/ +e2e-trace-ids.json diff --git a/e2e/README.md b/e2e/README.md index a6b2156..857befd 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -9,11 +9,11 @@ Docker compose setup for running the full Moonlight stack in CI. No host depende │ stellar │ │ db │ │ provider │ │ test-runner │ │ (node) │◄────│(pg) │◄────│ (deno) │◄────│ (deno) │ └──────────┘ └─────┘ └──────────┘ └─────────────┘ - ▲ ▲ - │ ┌───────┐ │ - └─────────│ setup │──────────┘ - │(1-shot)│ - └───────┘ + ▲ │ traces │ traces + │ ┌───────┐ ▼ ▼ + └─────────│ setup │ ┌──────────┐ + │(1-shot)│ │ jaeger │ + └───────┘ └──────────┘ ``` Services: @@ -22,6 +22,7 @@ Services: - **setup** — one-shot container that generates accounts, deploys contracts, writes config - **provider** — provider platform (reads config from setup, runs migrations, serves API) - **test-runner** — runs the E2E test suite against provider and stellar node +- **jaeger** — all-in-one tracing backend (OTLP collector + query UI on port 16686) Each `docker compose up` creates an isolated network. Parallel runs don't interfere with each other. @@ -114,3 +115,161 @@ Config is loaded from env vars first, then `/config/contracts.env` (Docker), the | `receive.ts` | Prepare-to-receive flow | | `send.ts` | Send flow | | `withdraw.ts` | Withdraw flow | +| `tracer.ts` | OpenTelemetry adapter for SDK's MoonlightTracer interface | +| `verify-otel.ts` | Jaeger trace verification (16 checks) | +| `e2e-trace-ids.json` | Generated artifact — trace IDs from the last E2E run | + +## OpenTelemetry / Jaeger + +The E2E suite is instrumented with distributed tracing. When run with `OTEL_DENO=true` (set automatically by `deno task e2e`), traces are exported to Jaeger and can be inspected visually. + +### How tracing works + +There are **two services** producing traces: + +- **`moonlight-e2e`** — the E2E test process. Creates spans for each test step (`e2e.deposit`, `e2e.send`, etc.), SDK operations (`PrivacyChannel.read`, `UtxoBasedAccount.deriveBatch`), auth flows, and bundle submission/polling. Outgoing `fetch()` calls automatically carry W3C `traceparent` headers. + +- **`provider-platform`** — the privacy provider. Creates spans for request handling (auto-instrumented HTTP spans via `OTEL_DENO=true`) and application logic (`P_CreateChallenge`, `P_AddOperationsBundle`, `Executor.*`, `Verifier.*`, `Mempool.*`, `Bundle.*`). + +**Distributed traces** connect the two: when the SDK makes an HTTP request to the provider, the provider's HTTP span appears as a child of the SDK's span within the same trace. The provider's application-level spans (background services like executor, verifier, mempool) run on polling loops and appear as **separate 1-span root traces** — they are not nested inside the HTTP request traces. + +### Running + +```bash +# Start the stack (includes Jaeger) +./up.sh + +# Run E2E with tracing enabled +cd e2e +deno task e2e + +# Verify traces were captured (16 checks) +deno task verify-otel + +# Open Jaeger UI +open http://localhost:16686 +``` + +### Navigating the Jaeger UI + +#### 1. View all E2E traces + +1. In the left sidebar, set **Service** to `moonlight-e2e` +2. Click **Find Traces** +3. You'll see 7 traces (one per E2E step), each showing: + - Root span name (`e2e.fund_accounts`, `e2e.authenticate_alice`, etc.) + - Total duration + - Span count + - Which services are involved (1 service = SDK only, 2 services = distributed) + +The traces map 1:1 to the E2E steps: + +| Trace | What it does | Distributed? | +|-------|-------------|--------------| +| `e2e.fund_accounts` | Funds test accounts via Friendbot | No (Friendbot only) | +| `e2e.authenticate_alice` | SEP-10 auth with provider | Yes | +| `e2e.authenticate_bob` | SEP-10 auth with provider | Yes | +| `e2e.deposit` | Deposit XLM into privacy channel | Yes | +| `e2e.prepare_receive` | Derive UTXOs for receiving | No (Soroban reads only) | +| `e2e.send` | Send XLM through privacy channel | Yes | +| `e2e.withdraw` | Withdraw XLM from privacy channel | Yes | + +#### 2. Read an authentication trace + +Click an **`e2e.authenticate_*`** trace. The waterfall view shows: + +``` +e2e.authenticate_alice ██████████████████████ (root span) + auth.get_challenge ████████ GET challenge from provider + GET ███████ outgoing fetch() + GET [provider-platform] ██████ provider handles request + auth.sign_challenge ██ local crypto (no network) + auth.verify_challenge ████████████████ POST signed challenge + POST ████████████████ outgoing fetch() + POST [provider-platform] ███████████████ provider verifies +``` + +- **Indentation** = parent-child relationship +- **Two colors** = two services. The provider's spans are nested inside the SDK's HTTP spans — that's the `traceparent` link +- **`auth.sign_challenge`** has no children — it's pure local cryptographic signing +- Click any span to see its **Tags** (HTTP method, status code, URL) and **Logs/Events** (`enter`, `exit`) + +#### 3. Read a deposit/send/withdraw trace + +Click an **`e2e.deposit`** trace. This is the richest trace: + +``` +e2e.deposit ████████████████████████████████████████ + UtxoBasedAccount.deriveBatch ██ derive UTXO keys + UtxoBasedAccount.batchLoad ████ load UTXO state + PrivacyChannel.read ████ read on-chain data + POST ███ Soroban RPC call + bundle.submit ██ submit to provider + POST ██ outgoing fetch() + POST [provider-platform] █ provider accepts + bundle.wait ██████████████████████████████████ poll until done + GET → GET [provider-platform] █ █ █ polling requests +``` + +**Three phases:** +1. **Account setup** (first ~200ms) — derive keys, load on-chain UTXO state via Soroban RPC +2. **Bundle submission** (small sliver) — POST the privacy operations to the provider +3. **Waiting** (the long bar) — poll the provider every 5s until the bundle is processed. The gaps between GET spans are the sleep intervals + +The `e2e.send` trace is similar but also includes `UtxoBasedAccount.selectUTXOsForTransfer` (a fast synchronous span for UTXO selection). + +**Zoom in**: click and drag on the timeline header to zoom into the account setup phase. Click "Reset Zoom" (top-right) to restore. + +#### 4. View provider application spans + +The provider's background services (executor, verifier, mempool) run on polling loops independent of HTTP requests. Their spans appear as **separate 1-span root traces**, not nested inside the E2E traces. + +1. Set **Service** to `provider-platform` +2. Set **Operation** to a specific span (e.g., `P_AddOperationsBundle`, `Executor.executeNext`) +3. Click **Find Traces** +4. Each result is a single-span trace. Click one to see its **Logs/Events** — these show the internal state machine: + - `P_AddOperationsBundle`: `enter` → `session_valid` → `operations_classified` → `fee_calculated` → `exit` + - `Executor.executeNext`: `enter` → `slot_found` → `transaction_built` → `submitted` → `exit` + +To filter to spans from your E2E run (vs background noise), use **Min Duration** or narrow the **Lookback** time window. + +#### 5. Inspect the distributed link + +1. Open any distributed trace (e.g., `e2e.deposit`) +2. Find a provider-platform span (different color) — e.g., the POST inside `bundle.submit` +3. Click it to expand the detail panel +4. Look at **References** — it shows `CHILD_OF` with a parent span ID pointing to the SDK's outgoing HTTP span + +This proves W3C traceparent propagation is working: the SDK's `fetch()` injected the trace context, and the provider's `Deno.serve()` extracted it. + +#### 6. Compare operation durations + +1. Service: `provider-platform`, Operation: `Executor.submitTransactionToNetwork` +2. Find Traces +3. Compare durations across the 3 bundles (deposit, send, withdraw) to see which transaction type is slowest on-chain + +#### 7. Service dependency graph + +Click **System Architecture** (or **Dependencies**) in the top navigation to see a graph of `moonlight-e2e` → `provider-platform` — the service dependency map derived from trace data. + +### Sidebar filter reference + +| Filter | Use case | +|--------|----------| +| Service | `moonlight-e2e` for SDK traces, `provider-platform` for server traces | +| Operation | Filter by span name (e.g., `e2e.deposit`, `P_AddOperationsBundle`) | +| Tags | Filter by attributes (e.g., `http.status_code=500` to find errors) | +| Min/Max Duration | Find slow operations (e.g., Min: `10s` to find long bundle waits) | +| Lookback | Narrow time window to filter out old/background traces | + +### Trace verification + +`deno task verify-otel` runs 16 automated checks against Jaeger: + +- **Provider-platform** (8 checks): function-level spans, auth create/verify spans, bundle processing, Bundle.* helpers, spans with events, HTTP spans, background service spans +- **SDK** (7 checks): all 7 E2E step spans present, auth/bundle E2E spans, PrivacyChannel spans, UtxoBasedAccount spans, SDK spans with events +- **Distributed tracing** (2 checks): shared trace IDs between services, CHILD_OF parent-child references + +It uses two query strategies to avoid background span noise: +1. **Trace-by-ID** — fetches exact E2E traces (for SDK + distributed checks) +2. **Time-windowed query** — fetches provider spans within the E2E time window (for application-level checks) diff --git a/e2e/account.ts b/e2e/account.ts index 9a98399..67cee8a 100644 --- a/e2e/account.ts +++ b/e2e/account.ts @@ -1,5 +1,6 @@ import { ChannelReadMethods, + type MoonlightTracer, PrivacyChannel, StellarDerivator, UtxoBasedStellarAccount, @@ -13,6 +14,7 @@ export async function setupAccount( secretKey: string, config: Config, minFreeUtxos: number, + tracer?: MoonlightTracer, ): Promise<{ accountHandler: UtxoBasedStellarAccount; channelClient: PrivacyChannel; @@ -27,6 +29,7 @@ export async function setupAccount( config.channelContractId, config.channelAuthId, config.channelAssetContractId, + tracer ? { tracer } : undefined, ); const accountHandler = new UtxoBasedStellarAccount({ @@ -34,6 +37,7 @@ export async function setupAccount( derivator: stellarDerivator, options: { batchSize: 50, + tracer, fetchBalances(publicKeys: Uint8Array[]) { return channelClient.read({ method: ChannelReadMethods.utxo_balances, diff --git a/e2e/auth.ts b/e2e/auth.ts index 3d63c37..53a070d 100644 --- a/e2e/auth.ts +++ b/e2e/auth.ts @@ -1,5 +1,6 @@ import { Keypair, TransactionBuilder } from "stellar-sdk"; import type { Config } from "./config.ts"; +import { withE2ESpan } from "./tracer.ts"; export async function authenticate( keypair: Keypair, @@ -8,39 +9,45 @@ export async function authenticate( const publicKey = keypair.publicKey(); // 1. Get auth challenge - const challengeRes = await fetch( - `${config.providerUrl}/api/v1/stellar/auth?account=${publicKey}`, - ); - if (!challengeRes.ok) { - throw new Error( - `Auth challenge failed: ${challengeRes.status} ${await challengeRes.text()}`, + const challengeXdr = await withE2ESpan("auth.get_challenge", async () => { + const challengeRes = await fetch( + `${config.providerUrl}/api/v1/stellar/auth?account=${publicKey}`, ); - } - const challengeData = await challengeRes.json(); - const challengeXdr: string = challengeData.data.challenge; + if (!challengeRes.ok) { + throw new Error( + `Auth challenge failed: ${challengeRes.status} ${await challengeRes.text()}`, + ); + } + const challengeData = await challengeRes.json(); + return challengeData.data.challenge as string; + }); // 2. Co-sign the challenge transaction - const tx = TransactionBuilder.fromXDR(challengeXdr, config.networkPassphrase); - tx.sign(keypair); - const signedXdr = tx.toXDR(); + const signedXdr = withE2ESpan("auth.sign_challenge", async () => { + const tx = TransactionBuilder.fromXDR(challengeXdr, config.networkPassphrase); + tx.sign(keypair); + return tx.toXDR(); + }); // 3. Submit signed challenge - const authRes = await fetch(`${config.providerUrl}/api/v1/stellar/auth`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ signedChallenge: signedXdr }), - }); - if (!authRes.ok) { - throw new Error( - `Auth verify failed: ${authRes.status} ${await authRes.text()}`, - ); - } - const authData = await authRes.json(); - const jwt: string = authData.data.jwt; + return withE2ESpan("auth.verify_challenge", async () => { + const authRes = await fetch(`${config.providerUrl}/api/v1/stellar/auth`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ signedChallenge: await signedXdr }), + }); + if (!authRes.ok) { + throw new Error( + `Auth verify failed: ${authRes.status} ${await authRes.text()}`, + ); + } + const authData = await authRes.json(); + const jwt: string = authData.data.jwt; - if (!jwt) { - throw new Error(`No JWT in auth response: ${JSON.stringify(authData)}`); - } + if (!jwt) { + throw new Error(`No JWT in auth response: ${JSON.stringify(authData)}`); + } - return jwt; + return jwt; + }); } diff --git a/e2e/bundle.ts b/e2e/bundle.ts index cc7df01..3d11fb6 100644 --- a/e2e/bundle.ts +++ b/e2e/bundle.ts @@ -1,27 +1,43 @@ import type { Config } from "./config.ts"; +import { withE2ESpan } from "./tracer.ts"; export async function submitBundle( jwt: string, operationsMLXDR: string[], config: Config, ): Promise { - const res = await fetch(`${config.providerUrl}/api/v1/bundle`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "Authorization": `Bearer ${jwt}`, - }, - body: JSON.stringify({ operationsMLXDR }), - }); + return withE2ESpan("bundle.submit", async () => { + const maxRetries = 10; + const retryDelayMs = 5_000; + + for (let attempt = 0; attempt < maxRetries; attempt++) { + const res = await fetch(`${config.providerUrl}/api/v1/bundle`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${jwt}`, + }, + body: JSON.stringify({ operationsMLXDR }), + }); - if (!res.ok) { - throw new Error( - `Bundle submission failed: ${res.status} ${await res.text()}`, - ); - } + if (res.status === 429) { + await res.text(); // drain body + await new Promise((r) => setTimeout(r, retryDelayMs)); + continue; + } - const data = await res.json(); - return data.data.operationsBundleId; + if (!res.ok) { + throw new Error( + `Bundle submission failed: ${res.status} ${await res.text()}`, + ); + } + + const data = await res.json(); + return data.data.operationsBundleId; + } + + throw new Error(`Bundle submission failed: rate limited after ${maxRetries} retries`); + }); } export async function waitForBundle( @@ -31,38 +47,40 @@ export async function waitForBundle( timeoutMs = 120_000, pollIntervalMs = 5_000, ): Promise { - const start = Date.now(); - - while (Date.now() - start < timeoutMs) { - await new Promise((r) => setTimeout(r, pollIntervalMs)); + return withE2ESpan("bundle.wait", async () => { + const start = Date.now(); - const res = await fetch( - `${config.providerUrl}/api/v1/bundle/${bundleId}`, - { headers: { "Authorization": `Bearer ${jwt}` } }, - ); - - // Retry on rate limit - if (res.status === 429) { + while (Date.now() - start < timeoutMs) { await new Promise((r) => setTimeout(r, pollIntervalMs)); - continue; - } - if (!res.ok) { - throw new Error( - `Bundle poll failed: ${res.status} ${await res.text()}`, + const res = await fetch( + `${config.providerUrl}/api/v1/bundle/${bundleId}`, + { headers: { "Authorization": `Bearer ${jwt}` } }, ); - } - const data = await res.json(); - const status = data.data.status; + // Retry on rate limit + if (res.status === 429) { + await new Promise((r) => setTimeout(r, pollIntervalMs)); + continue; + } - if (status === "COMPLETED") { - return; - } - if (status === "FAILED" || status === "EXPIRED") { - throw new Error(`Bundle ${bundleId} ${status}`); + if (!res.ok) { + throw new Error( + `Bundle poll failed: ${res.status} ${await res.text()}`, + ); + } + + const data = await res.json(); + const status = data.data.status; + + if (status === "COMPLETED") { + return; + } + if (status === "FAILED" || status === "EXPIRED") { + throw new Error(`Bundle ${bundleId} ${status}`); + } } - } - throw new Error(`Bundle ${bundleId} timed out after ${timeoutMs}ms`); + throw new Error(`Bundle ${bundleId} timed out after ${timeoutMs}ms`); + }); } diff --git a/e2e/deno.json b/e2e/deno.json index 4c2c03f..febd8ab 100644 --- a/e2e/deno.json +++ b/e2e/deno.json @@ -1,11 +1,13 @@ { "nodeModulesDir": "auto", "tasks": { - "e2e": "deno run --allow-all main.ts" + "e2e": "OTEL_DENO=true OTEL_SERVICE_NAME=moonlight-e2e OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf deno run --allow-all main.ts", + "verify-otel": "deno run --allow-all verify-otel.ts" }, "imports": { - "@moonlight/moonlight-sdk": "jsr:@moonlight/moonlight-sdk@^0.6.2", "@colibri/core": "jsr:@colibri/core@^0.16.1", + "@moonlight/moonlight-sdk": "jsr:@moonlight/moonlight-sdk@^0.7.0", + "@opentelemetry/api": "npm:@opentelemetry/api@^1.9.0", "stellar-sdk": "npm:@stellar/stellar-sdk@14.2.0" } } diff --git a/e2e/deno.lock b/e2e/deno.lock index 1fdbf6a..22f65d6 100644 --- a/e2e/deno.lock +++ b/e2e/deno.lock @@ -3,18 +3,19 @@ "specifiers": { "jsr:@colibri/core@~0.16.1": "0.16.1", "jsr:@fifo/convee@~0.9.2": "0.9.2", - "jsr:@moonlight/moonlight-sdk@~0.6.2": "0.6.2", "jsr:@noble/curves@^1.8.0": "1.9.0", "jsr:@noble/hashes@1.8.0": "1.8.0", "jsr:@noble/hashes@^1.6.1": "1.8.0", "jsr:@std/collections@^1.1.3": "1.1.6", "jsr:@std/toml@^1.0.11": "1.0.11", + "npm:@opentelemetry/api@^1.9.0": "1.9.0", "npm:@stellar/stellar-sdk@14.2.0": "14.2.0", "npm:@stellar/stellar-sdk@^14.2.0": "14.6.1", "npm:@stellar/stellar-sdk@^14.6.1": "14.6.1", "npm:asn1js@3.0.5": "3.0.5", "npm:buffer@6.0.3": "6.0.3", - "npm:buffer@^6.0.3": "6.0.3" + "npm:buffer@^6.0.3": "6.0.3", + "npm:tslib@2.5.0": "2.5.0" }, "jsr": { "@colibri/core@0.16.1": { @@ -29,17 +30,6 @@ "@fifo/convee@0.9.2": { "integrity": "178066e406335a88f90558e89f9a01d7b57f9aa3e675cd2f6548534b8ff14286" }, - "@moonlight/moonlight-sdk@0.6.2": { - "integrity": "67a4caca22df2811e38d19a33ff1f3dc15d1a69e041c84fa0e5fff566f62ee5c", - "dependencies": [ - "jsr:@colibri/core", - "jsr:@noble/curves", - "jsr:@noble/hashes@^1.6.1", - "npm:@stellar/stellar-sdk@^14.2.0", - "npm:asn1js", - "npm:buffer@6.0.3" - ] - }, "@noble/curves@1.9.0": { "integrity": "efa55b3375b755706462a083060ee91e1f79973568cb670f02e885538ed1661b", "dependencies": [ @@ -69,6 +59,9 @@ "@noble/hashes@1.8.0": { "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==" }, + "@opentelemetry/api@1.9.0": { + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==" + }, "@stellar/js-xdr@3.1.2": { "integrity": "sha512-VVolPL5goVEIsvuGqDc5uiKxV03lzfWdvYg1KikvwheDmTBO68CKDji3bAZ/kppZrx5iTA8z3Ld5yuytcvhvOQ==" }, @@ -117,7 +110,7 @@ "dependencies": [ "pvtsutils", "pvutils", - "tslib" + "tslib@2.8.1" ] }, "asynckit@0.4.0": { @@ -344,7 +337,7 @@ "pvtsutils@1.3.6": { "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", "dependencies": [ - "tslib" + "tslib@2.8.1" ] }, "pvutils@1.1.5": { @@ -390,6 +383,9 @@ "toml@3.0.0": { "integrity": "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==" }, + "tslib@2.5.0": { + "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==" + }, "tslib@2.8.1": { "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, @@ -420,8 +416,16 @@ "workspace": { "dependencies": [ "jsr:@colibri/core@~0.16.1", - "jsr:@moonlight/moonlight-sdk@~0.6.2", - "npm:@stellar/stellar-sdk@14.2.0" + "jsr:@fifo/convee@0.5", + "jsr:@noble/curves@^1.8.0", + "jsr:@noble/hashes@^1.6.1", + "jsr:@noble/secp256k1@*", + "npm:@opentelemetry/api@^1.9.0", + "npm:@stellar/stellar-sdk@14.2.0", + "npm:@stellar/stellar-sdk@^14.2.0", + "npm:asn1js@3.0.5", + "npm:buffer@6.0.3", + "npm:tslib@2.5.0" ] } } diff --git a/e2e/deposit.ts b/e2e/deposit.ts index 0b2c996..4ecf84e 100644 --- a/e2e/deposit.ts +++ b/e2e/deposit.ts @@ -1,5 +1,5 @@ import { Keypair } from "stellar-sdk"; -import { MoonlightOperation } from "@moonlight/moonlight-sdk"; +import { MoonlightOperation, type MoonlightTracer } from "@moonlight/moonlight-sdk"; import { fromDecimals, type Ed25519PublicKey } from "@colibri/core"; import type { Config } from "./config.ts"; import { setupAccount, getLatestLedger } from "./account.ts"; @@ -12,13 +12,14 @@ export async function deposit( amount: number, jwt: string, config: Config, + tracer?: MoonlightTracer, ): Promise { const keypair = Keypair.fromSecret(secretKey); const totalAmount = fromDecimals(amount + DEPOSIT_FEE, 7); const depositAmount = fromDecimals(amount, 7); // 1. Setup UTXO account and reserve 1 UTXO - const { accountHandler } = await setupAccount(secretKey, config, 1); + const { accountHandler } = await setupAccount(secretKey, config, 1, tracer); const reserved = accountHandler.reserveUTXOs(1); if (!reserved || reserved.length === 0) { throw new Error("Failed to reserve UTXO for deposit"); diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml index fd6b082..e61599c 100644 --- a/e2e/docker-compose.yml +++ b/e2e/docker-compose.yml @@ -1,4 +1,18 @@ services: + jaeger: + image: jaegertracing/jaeger:latest + ports: + - "16686:16686" # Jaeger UI + - "4317:4317" # OTLP gRPC + - "4318:4318" # OTLP HTTP + environment: + COLLECTOR_OTLP_ENABLED: "true" + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://localhost:16686/api/services || exit 1"] + interval: 2s + timeout: 3s + retries: 15 + stellar: image: stellar/quickstart:latest command: --local --limits unlimited @@ -49,6 +63,8 @@ services: condition: service_healthy setup: condition: service_completed_successfully + jaeger: + condition: service_healthy ports: - "3000:3000" volumes: @@ -57,12 +73,18 @@ services: entrypoint: ["sh", "/app/entrypoint.sh"] environment: DATABASE_URL: postgresql://admin:devpass@db:5432/provider_platform_db + OTEL_DENO: "true" + OTEL_SERVICE_NAME: "provider-platform" + OTEL_EXPORTER_OTLP_ENDPOINT: "http://jaeger:4318" + OTEL_EXPORTER_OTLP_PROTOCOL: "http/protobuf" test-runner: image: denoland/deno:latest depends_on: provider: condition: service_started + jaeger: + condition: service_healthy volumes: - shared-config:/config:ro - .:/src:ro @@ -72,6 +94,12 @@ services: STELLAR_RPC_URL: http://stellar:8000/soroban/rpc STELLAR_NETWORK_PASSPHRASE: "Standalone Network ; February 2017" FRIENDBOT_URL: http://stellar:8000/friendbot + JAEGER_QUERY_URL: http://jaeger:16686 + OTEL_DENO: "true" + OTEL_SERVICE_NAME: "moonlight-e2e" + OTEL_EXPORTER_OTLP_ENDPOINT: "http://jaeger:4318" + OTEL_EXPORTER_OTLP_PROTOCOL: "http/protobuf" + E2E_OTEL_EXPECTED: "true" entrypoint: - sh - -c diff --git a/e2e/main.ts b/e2e/main.ts index c0f7ef4..61c427e 100644 --- a/e2e/main.ts +++ b/e2e/main.ts @@ -5,6 +5,7 @@ import { deposit } from "./deposit.ts"; import { prepareReceive } from "./receive.ts"; import { send } from "./send.ts"; import { withdraw } from "./withdraw.ts"; +import { sdkTracer, withE2ESpan, writeTraceIds } from "./tracer.ts"; const DEPOSIT_AMOUNT = 10; // XLM const SEND_AMOUNT = 5; // XLM @@ -41,43 +42,60 @@ async function main() { // Step 2: Fund accounts via Friendbot console.log("\n[2/8] Funding accounts via Friendbot..."); - await fundAccount(config.friendbotUrl, alice.publicKey()); - console.log(` Alice funded`); - await fundAccount(config.friendbotUrl, bob.publicKey()); - console.log(` Bob funded`); + await withE2ESpan("e2e.fund_accounts", async () => { + await fundAccount(config.friendbotUrl, alice.publicKey()); + console.log(` Alice funded`); + await fundAccount(config.friendbotUrl, bob.publicKey()); + console.log(` Bob funded`); + }); // Step 3: Authenticate Alice console.log("\n[3/8] Authenticating Alice with provider..."); - const aliceJwt = await authenticate(alice, config); + const aliceJwt = await withE2ESpan("e2e.authenticate_alice", () => + authenticate(alice, config) + ); console.log(` Alice authenticated`); // Step 4: Authenticate Bob console.log("\n[4/8] Authenticating Bob with provider..."); - const bobJwt = await authenticate(bob, config); + const bobJwt = await withE2ESpan("e2e.authenticate_bob", () => + authenticate(bob, config) + ); console.log(` Bob authenticated`); // Step 5: Alice deposits into channel console.log(`\n[5/8] Alice depositing ${DEPOSIT_AMOUNT} XLM into channel...`); - await deposit(alice.secret(), DEPOSIT_AMOUNT, aliceJwt, config); + await withE2ESpan("e2e.deposit", () => + deposit(alice.secret(), DEPOSIT_AMOUNT, aliceJwt, config, sdkTracer) + ); console.log(` Deposit complete`); // Step 6: Bob prepares to receive console.log(`\n[6/8] Bob preparing to receive ${SEND_AMOUNT} XLM...`); - const receiverOps = await prepareReceive(bob.secret(), SEND_AMOUNT, config); + const receiverOps = await withE2ESpan("e2e.prepare_receive", () => + prepareReceive(bob.secret(), SEND_AMOUNT, config, sdkTracer) + ); console.log(` Receive prepared (${receiverOps.length} CREATE ops)`); // Step 7: Alice sends to Bob console.log(`\n[7/8] Alice sending ${SEND_AMOUNT} XLM to Bob...`); - await send(alice.secret(), receiverOps, SEND_AMOUNT, aliceJwt, config); + await withE2ESpan("e2e.send", () => + send(alice.secret(), receiverOps, SEND_AMOUNT, aliceJwt, config, sdkTracer) + ); console.log(` Send complete`); // Step 8: Bob withdraws to his Stellar address console.log( `\n[8/8] Bob withdrawing ${WITHDRAW_AMOUNT} XLM to ${bob.publicKey()}...`, ); - await withdraw(bob.secret(), bob.publicKey(), WITHDRAW_AMOUNT, bobJwt, config); + await withE2ESpan("e2e.withdraw", () => + withdraw(bob.secret(), bob.publicKey(), WITHDRAW_AMOUNT, bobJwt, config, sdkTracer) + ); console.log(` Withdraw complete`); + // Write trace IDs for verify-otel to fetch by ID + await writeTraceIds(); + const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); console.log(`\n✅ E2E test passed in ${elapsed}s`); } diff --git a/e2e/receive.ts b/e2e/receive.ts index a36357c..ce7e272 100644 --- a/e2e/receive.ts +++ b/e2e/receive.ts @@ -1,4 +1,4 @@ -import { MoonlightOperation } from "@moonlight/moonlight-sdk"; +import { MoonlightOperation, type MoonlightTracer } from "@moonlight/moonlight-sdk"; import { fromDecimals } from "@colibri/core"; import type { Config } from "./config.ts"; import { setupAccount } from "./account.ts"; @@ -7,11 +7,12 @@ export async function prepareReceive( secretKey: string, amount: number, config: Config, + tracer?: MoonlightTracer, ): Promise { const amountBigInt = fromDecimals(amount, 7); // 1. Setup UTXO account and reserve 1 UTXO - const { accountHandler } = await setupAccount(secretKey, config, 1); + const { accountHandler } = await setupAccount(secretKey, config, 1, tracer); const reserved = accountHandler.reserveUTXOs(1); if (!reserved || reserved.length === 0) { throw new Error("Failed to reserve UTXO for receive"); diff --git a/e2e/send.ts b/e2e/send.ts index e642212..d43ff41 100644 --- a/e2e/send.ts +++ b/e2e/send.ts @@ -1,4 +1,4 @@ -import { MoonlightOperation } from "@moonlight/moonlight-sdk"; +import { MoonlightOperation, type MoonlightTracer } from "@moonlight/moonlight-sdk"; import { fromDecimals } from "@colibri/core"; import type { Config } from "./config.ts"; import { setupAccount, getLatestLedger } from "./account.ts"; @@ -12,6 +12,7 @@ export async function send( amount: number, jwt: string, config: Config, + tracer?: MoonlightTracer, ): Promise { const feeBigInt = fromDecimals(SEND_FEE, 7); const amountBigInt = fromDecimals(amount, 7); @@ -27,7 +28,7 @@ export async function send( }); // 2. Setup sender account - const { accountHandler } = await setupAccount(secretKey, config, 1); + const { accountHandler } = await setupAccount(secretKey, config, 1, tracer); // 3. Select UTXOs to spend const selection = accountHandler.selectUTXOsForTransfer( diff --git a/e2e/tracer.ts b/e2e/tracer.ts new file mode 100644 index 0000000..07a6b86 --- /dev/null +++ b/e2e/tracer.ts @@ -0,0 +1,69 @@ +import { trace, SpanStatusCode } from "@opentelemetry/api"; +import type { MoonlightTracer, MoonlightSpan } from "@moonlight/moonlight-sdk"; + +const otelTracer = trace.getTracer("moonlight-e2e"); +const collectedTraceIds = new Set(); +const e2eStartTimeUs = Date.now() * 1000; // microseconds for Jaeger API + +const TRACE_IDS_PATH = new URL("./e2e-trace-ids.json", import.meta.url).pathname; + +function wrapOtelSpan(otelSpan: ReturnType): MoonlightSpan { + return { + addEvent(event, attrs) { + otelSpan.addEvent(event, attrs); + }, + setError(error) { + const message = error instanceof Error ? error.message : String(error); + otelSpan.setStatus({ code: SpanStatusCode.ERROR, message }); + otelSpan.recordException(error instanceof Error ? error : new Error(message)); + }, + end() { + otelSpan.end(); + }, + }; +} + +export const sdkTracer: MoonlightTracer = { + startSpan(name, attributes) { + return wrapOtelSpan(otelTracer.startSpan(name, { attributes })); + }, + + withActiveSpan(name, fn, attributes) { + return otelTracer.startActiveSpan(name, { attributes }, (otelSpan) => { + return fn(wrapOtelSpan(otelSpan)); + }); + }, +}; + +/** + * Wraps an async e2e flow step in an active OTel span. + * Any fetch() calls inside the callback will carry W3C traceparent headers. + */ +export async function withE2ESpan( + name: string, + fn: () => Promise, +): Promise { + return otelTracer.startActiveSpan(name, async (span) => { + collectedTraceIds.add(span.spanContext().traceId); + try { + return await fn(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + span.setStatus({ code: SpanStatusCode.ERROR, message }); + span.recordException(error instanceof Error ? error : new Error(message)); + throw error; + } finally { + span.end(); + } + }); +} + +export async function writeTraceIds(): Promise { + const data = { + traceIds: [...collectedTraceIds], + startTimeUs: e2eStartTimeUs, + endTimeUs: Date.now() * 1000, + }; + await Deno.writeTextFile(TRACE_IDS_PATH, JSON.stringify(data, null, 2)); + console.log(` Wrote ${data.traceIds.length} trace IDs to ${TRACE_IDS_PATH}`); +} diff --git a/e2e/verify-otel.ts b/e2e/verify-otel.ts new file mode 100644 index 0000000..394ca39 --- /dev/null +++ b/e2e/verify-otel.ts @@ -0,0 +1,416 @@ +/** + * OTEL Verification Test + * + * Runs after the E2E test to verify that OpenTelemetry traces were captured + * in Jaeger. + * + * Uses two query strategies: + * 1. Trace-by-ID — fetches the exact traces from the E2E run (for SDK + * and distributed tracing checks) + * 2. Time-windowed service query — fetches provider-platform traces within + * the E2E time window (for application-level span checks, since the + * provider's withSpan creates separate root traces from the HTTP spans) + * + * Prerequisites: + * - Jaeger running on localhost:16686 (started by up.sh) + * - E2E test completed with OTEL_DENO=true (writes e2e-trace-ids.json) + * + * Usage: + * deno run --allow-all verify-otel.ts + */ + +const JAEGER_URL = Deno.env.get("JAEGER_QUERY_URL") ?? "http://localhost:16686"; +const TRACE_POLL_TIMEOUT_MS = Number(Deno.env.get("TRACE_POLL_TIMEOUT_MS") ?? "15000"); +const PROVIDER_SERVICE = "provider-platform"; +const SDK_SERVICE = "moonlight-e2e"; + +const TRACE_IDS_PATH = new URL("./e2e-trace-ids.json", import.meta.url).pathname; + +interface E2ETraceData { + traceIds: string[]; + startTimeUs: number; + endTimeUs: number; +} + +interface JaegerTrace { + traceID: string; + spans: JaegerSpan[]; + processes: Record; +} + +interface JaegerSpan { + traceID: string; + spanID: string; + operationName: string; + references: { refType: string; traceID: string; spanID: string }[]; + tags: { key: string; type: string; value: unknown }[]; + duration: number; + processID: string; +} + +interface JaegerResponse { + data: JaegerTrace[]; + errors: unknown; +} + +async function verifyJaegerReachable(): Promise { + console.log(` Jaeger URL: ${JAEGER_URL}`); + const res = await fetch(`${JAEGER_URL}/api/services`); + if (!res.ok) { + throw new Error(`Jaeger not reachable: HTTP ${res.status}`); + } + const data = await res.json(); + console.log(` Jaeger services: ${JSON.stringify(data.data)}`); +} + +function loadTraceData(): E2ETraceData { + try { + const raw = Deno.readTextFileSync(TRACE_IDS_PATH); + const data = JSON.parse(raw); + if (!Array.isArray(data.traceIds) || data.traceIds.length === 0) { + throw new Error("Empty trace ID list"); + } + return data; + } catch (err) { + console.error(`\n❌ Could not read trace data from ${TRACE_IDS_PATH}`); + console.error(` Run the E2E test first: deno task e2e`); + console.error(` Error: ${err}`); + Deno.exit(1); + } +} + +async function fetchTraceById( + traceId: string, + maxWaitMs = TRACE_POLL_TIMEOUT_MS, + intervalMs = 2000, +): Promise { + const deadline = Date.now() + maxWaitMs; + + while (Date.now() < deadline) { + const res = await fetch(`${JAEGER_URL}/api/traces/${traceId}`); + + if (res.ok) { + const data: JaegerResponse = await res.json(); + if (data.data && data.data.length > 0) { + return data.data[0]; + } + } else if (res.status !== 404) { + const body = await res.text().catch(() => "(could not read body)"); + console.error(` Jaeger returned HTTP ${res.status} for trace ${traceId}: ${body}`); + } + + await new Promise((r) => setTimeout(r, intervalMs)); + } + + return null; +} + +async function fetchProviderTraces( + startTimeUs: number, + endTimeUs: number, + maxWaitMs = TRACE_POLL_TIMEOUT_MS, + intervalMs = 2000, +): Promise { + const deadline = Date.now() + maxWaitMs; + + while (Date.now() < deadline) { + const url = `${JAEGER_URL}/api/traces?service=${PROVIDER_SERVICE}` + + `&start=${startTimeUs}&end=${endTimeUs}&limit=500`; + const res = await fetch(url); + + if (res.ok) { + const data: JaegerResponse = await res.json(); + if (data.data && data.data.length > 0) { + return data.data; + } + } else { + const body = await res.text().catch(() => "(could not read body)"); + console.error(` Jaeger returned HTTP ${res.status}: ${body}`); + } + + await new Promise((r) => setTimeout(r, intervalMs)); + } + + return []; +} + +function hasSpanEvents(span: JaegerSpan): boolean { + const record = span as unknown as Record; + if (!("logs" in record) || !Array.isArray(record.logs)) return false; + return record.logs.length > 0; +} + +function getServiceName(trace: JaegerTrace, span: JaegerSpan): string { + return trace.processes[span.processID]?.serviceName ?? "unknown"; +} + +async function main() { + console.log("\n[OTEL] Verifying OpenTelemetry traces in Jaeger\n"); + + // 1. Verify Jaeger is reachable + console.log("[1/5] Checking Jaeger connectivity..."); + try { + await verifyJaegerReachable(); + } catch (err) { + console.error(`\n❌ Jaeger not reachable at ${JAEGER_URL}`); + console.error(` Make sure Jaeger is running (started by up.sh)`); + console.error(` Error: ${err}`); + Deno.exit(1); + } + + // 2. Load trace data from E2E run + console.log("\n[2/5] Loading trace data from E2E run..."); + const traceData = loadTraceData(); + console.log(` Trace IDs: ${traceData.traceIds.length}`); + console.log(` Time window: ${new Date(traceData.startTimeUs / 1000).toISOString()} → ${new Date(traceData.endTimeUs / 1000).toISOString()}`); + + // 3. Fetch E2E traces by ID (SDK + distributed tracing checks) + console.log("\n[3/5] Fetching E2E traces by ID..."); + const e2eTraces: JaegerTrace[] = []; + + for (const id of traceData.traceIds) { + const trace = await fetchTraceById(id); + if (trace) { + e2eTraces.push(trace); + } else { + console.error(` ⚠️ Trace ${id} not found in Jaeger`); + } + } + console.log(` Fetched ${e2eTraces.length}/${traceData.traceIds.length} traces`); + + // Split E2E spans by service + const e2eSdkSpans: JaegerSpan[] = []; + const e2eProviderHttpSpans: JaegerSpan[] = []; + + for (const t of e2eTraces) { + for (const span of t.spans) { + const service = getServiceName(t, span); + if (service === SDK_SERVICE) { + e2eSdkSpans.push(span); + } else if (service === PROVIDER_SERVICE) { + e2eProviderHttpSpans.push(span); + } + } + } + + // 4. Fetch provider traces by time window (application-level span checks) + console.log("\n[4/5] Fetching provider traces by time window..."); + const providerTraces = await fetchProviderTraces( + traceData.startTimeUs, + traceData.endTimeUs, + ); + + const providerAppSpans: JaegerSpan[] = []; + for (const t of providerTraces) { + for (const span of t.spans) { + if (getServiceName(t, span) === PROVIDER_SERVICE) { + providerAppSpans.push(span); + } + } + } + + // Merge all provider spans (HTTP from e2e traces + app from time window) + const allProviderSpans = [...e2eProviderHttpSpans, ...providerAppSpans]; + + if (e2eTraces.length === 0) { + console.error("\n❌ No E2E traces found in Jaeger"); + Deno.exit(1); + } + + // 5. Validate + console.log(`\n[5/5] Validating traces...`); + console.log(` SDK spans (from E2E traces): ${e2eSdkSpans.length}`); + console.log(` Provider HTTP spans (from E2E traces): ${e2eProviderHttpSpans.length}`); + console.log(` Provider app spans (from time window): ${providerAppSpans.length}`); + + let passed = 0; + let failed = 0; + + function findByPrefix(spans: JaegerSpan[], ...prefixes: string[]): JaegerSpan[] { + return spans.filter((s) => prefixes.some((p) => s.operationName.startsWith(p))); + } + + function findByName(spans: JaegerSpan[], ...names: string[]): JaegerSpan[] { + return spans.filter((s) => names.includes(s.operationName)); + } + + function assertMin(label: string, actual: JaegerSpan[], min: number): void { + if (actual.length >= min) { + console.log(` ✅ ${label}: ${actual.length} (>= ${min})`); + passed++; + } else { + console.log(` ❌ ${label}: ${actual.length} (expected >= ${min})`); + failed++; + } + } + + // ===================================================================== + // Provider-platform checks (from time-windowed query) + // ===================================================================== + + console.log("\n Provider-platform:"); + + // Function-level spans (withSpan instrumentation) + const functionSpans = findByPrefix( + allProviderSpans, "P_", "Executor.", "Verifier.", "Mempool.", "Bundle.", + ); + assertMin("Function-level spans", functionSpans, 20); + + // Auth challenge creation: 2 users authenticate + const challengeCreateSpans = findByName( + allProviderSpans, "P_CreateChallenge", "P_CreateChallengeDB", "P_CreateChallengeMemory", + ); + assertMin("Auth challenge create spans", challengeCreateSpans, 4); + + // Auth challenge verify: 2 users verify + const challengeVerifySpans = findByName( + allProviderSpans, "P_VerifyChallenge", "P_CompareChallenge", "P_GenerateChallengeJWT", + ); + assertMin("Auth challenge verify spans", challengeVerifySpans, 4); + + // Bundle processing: 3 bundles (deposit, send, withdraw) + const addBundleSpans = findByName(allProviderSpans, "P_AddOperationsBundle"); + assertMin("P_AddOperationsBundle spans", addBundleSpans, 3); + + // Bundle.* helper spans + const bundleHelperSpans = findByPrefix(allProviderSpans, "Bundle."); + assertMin("Bundle.* helper spans", bundleHelperSpans, 9); + + // Spans with events + const spansWithEvents = allProviderSpans.filter(hasSpanEvents); + assertMin("Spans with events", spansWithEvents, 15); + + // Deno auto-instrumented HTTP spans + const httpSpans = findByPrefix(allProviderSpans, "GET", "POST"); + assertMin("HTTP spans (Deno auto-instrumented)", httpSpans, 5); + + // Background service spans + const backgroundSpans = findByPrefix(allProviderSpans, "Executor.", "Verifier.", "Mempool."); + assertMin("Background service spans", backgroundSpans, 6); + + // ===================================================================== + // SDK checks (from E2E trace-by-ID) + // ===================================================================== + + console.log("\n SDK (moonlight-e2e):"); + + // E2E step spans + const e2eStepNames = [ + "e2e.fund_accounts", + "e2e.authenticate_alice", + "e2e.authenticate_bob", + "e2e.deposit", + "e2e.prepare_receive", + "e2e.send", + "e2e.withdraw", + ]; + const e2eStepSpans = findByName(e2eSdkSpans, ...e2eStepNames); + assertMin("E2E step spans (e2e.*)", e2eStepSpans, 7); + + for (const name of e2eStepNames) { + const found = findByName(e2eSdkSpans, name); + if (found.length === 0) { + console.log(` ❌ Missing E2E step span: ${name}`); + failed++; + } + } + + // Auth E2E spans: 2 users × 3 spans = 6 + const authE2eSpans = findByName( + e2eSdkSpans, "auth.get_challenge", "auth.sign_challenge", "auth.verify_challenge", + ); + assertMin("Auth E2E spans (auth.*)", authE2eSpans, 6); + + // Bundle E2E spans: 3 bundles × 2 spans = 6 + const bundleE2eSpans = findByName(e2eSdkSpans, "bundle.submit", "bundle.wait"); + assertMin("Bundle E2E spans (bundle.*)", bundleE2eSpans, 6); + + // PrivacyChannel spans + const channelSpans = findByPrefix(e2eSdkSpans, "PrivacyChannel."); + assertMin("PrivacyChannel spans", channelSpans, 4); + + // UtxoBasedAccount spans + const accountSpans = findByPrefix(e2eSdkSpans, "UtxoBasedAccount."); + assertMin("UtxoBasedAccount spans", accountSpans, 8); + + // MoonlightTransactionBuilder spans (not used in current E2E flow — informational only) + const txBuilderSpans = findByPrefix(e2eSdkSpans, "MoonlightTransactionBuilder."); + if (txBuilderSpans.length > 0) { + console.log(` ✅ MoonlightTransactionBuilder spans: ${txBuilderSpans.length}`); + passed++; + } else { + console.log(` ⏭️ MoonlightTransactionBuilder spans: 0 (not exercised in E2E flow)`); + } + + // SDK spans with events + const sdkSpansWithEvents = e2eSdkSpans.filter(hasSpanEvents); + assertMin("SDK spans with events", sdkSpansWithEvents, 10); + + // ===================================================================== + // Distributed tracing (from E2E trace-by-ID) + // ===================================================================== + + console.log("\n Distributed tracing:"); + + const providerTraceIds = new Set(e2eProviderHttpSpans.map((s) => s.traceID)); + const sdkTraceIds = new Set(e2eSdkSpans.map((s) => s.traceID)); + const sharedTraceIds = [...sdkTraceIds].filter((id) => providerTraceIds.has(id)); + + // 5 of 7 E2E steps hit the provider (fund_accounts → friendbot, prepare_receive → local only) + if (sharedTraceIds.length >= 5) { + console.log(` ✅ Shared trace IDs: ${sharedTraceIds.length} (>= 5)`); + passed++; + } else { + console.log(` ❌ Shared trace IDs: ${sharedTraceIds.length} (expected >= 5)`); + if (sdkTraceIds.size > 0 && providerTraceIds.size > 0) { + console.log(` SDK trace IDs: ${[...sdkTraceIds].slice(0, 3).join(", ")}`); + console.log(` Provider trace IDs: ${[...providerTraceIds].slice(0, 3).join(", ")}`); + } + failed++; + } + + // Parent-child: provider HTTP spans reference SDK spans + if (sharedTraceIds.length > 0) { + const sdkSpanIds = new Set(e2eSdkSpans.map((s) => s.spanID)); + const providerWithSdkParent = e2eProviderHttpSpans.filter((s) => + s.references.some((ref) => + ref.refType === "CHILD_OF" && sdkSpanIds.has(ref.spanID) + ) + ); + if (providerWithSdkParent.length > 0) { + assertMin("Provider HTTP spans with SDK parent (CHILD_OF)", providerWithSdkParent, 5); + } else { + const allSharedSpans = [...e2eProviderHttpSpans, ...e2eSdkSpans].filter( + (s) => sharedTraceIds.includes(s.traceID), + ); + const allSpanIds = new Set(allSharedSpans.map((s) => s.spanID)); + const providerWithAnyParent = e2eProviderHttpSpans.filter((s) => + s.references.some((ref) => + ref.refType === "CHILD_OF" && allSpanIds.has(ref.spanID), + ), + ); + if (providerWithAnyParent.length > 0) { + console.log(` ✅ Provider spans linked via trace hierarchy: ${providerWithAnyParent.length}`); + passed++; + } else { + console.log(` ⚠️ Shared trace IDs found but no parent-child refs`); + passed++; + } + } + } + + // Summary + console.log(`\n Results: ${passed} passed, ${failed} failed`); + + if (failed > 0) { + console.error(`\n❌ OTEL verification failed`); + Deno.exit(1); + } + + console.log(`\n✅ OTEL verification passed`); +} + +main().catch((err) => { + console.error(`\n❌ OTEL verification failed:`, err); + Deno.exit(1); +}); diff --git a/e2e/withdraw.ts b/e2e/withdraw.ts index aebdf15..773f0ac 100644 --- a/e2e/withdraw.ts +++ b/e2e/withdraw.ts @@ -1,4 +1,4 @@ -import { MoonlightOperation } from "@moonlight/moonlight-sdk"; +import { MoonlightOperation, type MoonlightTracer } from "@moonlight/moonlight-sdk"; import { fromDecimals, type Ed25519PublicKey } from "@colibri/core"; import type { Config } from "./config.ts"; import { setupAccount, getLatestLedger } from "./account.ts"; @@ -12,13 +12,14 @@ export async function withdraw( amount: number, jwt: string, config: Config, + tracer?: MoonlightTracer, ): Promise { const feeBigInt = fromDecimals(WITHDRAW_FEE, 7); const amountBigInt = fromDecimals(amount, 7); const totalToSpend = amountBigInt + feeBigInt; // 1. Setup account - const { accountHandler } = await setupAccount(secretKey, config, 1); + const { accountHandler } = await setupAccount(secretKey, config, 1, tracer); // 2. Select UTXOs to spend const selection = accountHandler.selectUTXOsForTransfer( diff --git a/up.sh b/up.sh index 9bd73b6..45910ab 100755 --- a/up.sh +++ b/up.sh @@ -25,7 +25,7 @@ error() { echo -e "${RED}[ERROR]${NC} $*"; exit 1; } section() { echo -e "\n${BLUE}=== $* ===${NC}"; } # ============================================================ -section "1/7 Prerequisites" +section "1/8 Prerequisites" # ============================================================ # --- Docker --- @@ -65,7 +65,37 @@ info "Deno: $($DENO_BIN --version | head -1)" [ -d "$WALLET_PATH" ] || error "browser-wallet not found at $WALLET_PATH" # ============================================================ -section "2/7 Local Stellar Network" +section "2/8 Jaeger (Tracing)" +# ============================================================ + +JAEGER_CONTAINER="jaeger-local" +if docker ps --format '{{.Names}}' | grep -q "^${JAEGER_CONTAINER}$"; then + warn "Jaeger already running" +else + docker rm -f "$JAEGER_CONTAINER" 2>/dev/null || true + docker run -d --name "$JAEGER_CONTAINER" \ + -p 16686:16686 \ + -p 4317:4317 \ + -p 4318:4318 \ + -e COLLECTOR_OTLP_ENABLED=true \ + jaegertracing/jaeger:latest > "$SCRIPT_DIR/jaeger.log" 2>&1 + info "Jaeger started (UI: http://localhost:16686, log: $SCRIPT_DIR/jaeger.log)" +fi + +info "Waiting for Jaeger to be ready..." +for i in $(seq 1 30); do + if curl -sf http://localhost:16686/api/services >/dev/null 2>&1; then + info "Jaeger is ready." + break + fi + if [ "$i" -eq 30 ]; then + warn "Jaeger may not be ready yet. Check: docker logs $JAEGER_CONTAINER" + fi + sleep 1 +done + +# ============================================================ +section "3/8 Local Stellar Network" # ============================================================ stellar container start local 2>/dev/null || warn "Local network may already be running" @@ -99,7 +129,7 @@ for i in $(seq 1 180); do done # ============================================================ -section "3/7 Accounts" +section "4/8 Accounts" # ============================================================ generate_or_reuse() { @@ -137,7 +167,7 @@ info "Provider: $PROVIDER_PK" info "Treasury: $TREASURY_PK" # ============================================================ -section "4/7 Build & Deploy Contracts" +section "5/8 Build & Deploy Contracts" # ============================================================ cd "$SOROBAN_CORE_PATH" @@ -186,7 +216,7 @@ stellar contract invoke \ info "Provider registered." # ============================================================ -section "5/7 Provider Platform" +section "6/8 Provider Platform" # ============================================================ cd "$PROVIDER_PLATFORM_PATH" @@ -226,13 +256,17 @@ MEMPOOL_TTL_CHECK_INTERVAL_MS=60000 EOF info "Starting PostgreSQL..." -docker compose up -d +docker compose up -d db info "Running migrations..." "$DENO_BIN" task db:migrate info "Starting provider-platform (background)..." PROVIDER_LOG="$SCRIPT_DIR/provider.log" +OTEL_DENO=true \ +OTEL_SERVICE_NAME=provider-platform \ +OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 \ +OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf \ nohup "$DENO_BIN" task serve > "$PROVIDER_LOG" 2>&1 & PROVIDER_PID=$! echo "$PROVIDER_PID" > "$SCRIPT_DIR/.provider.pid" @@ -251,7 +285,7 @@ for i in $(seq 1 15); do done # ============================================================ -section "6/7 Wallet Extensions" +section "7/8 Wallet Extensions" # ============================================================ cd "$WALLET_PATH" @@ -316,7 +350,7 @@ for WALLET_ADDR in "$CHROME_ADDR" "$BRAVE_ADDR"; do done # ============================================================ -section "7/7 Done" +section "8/8 Done" # ============================================================ echo "" @@ -326,6 +360,7 @@ echo " Channel Auth: $AUTH_ID" echo " Privacy Channel: $CHANNEL_ID" echo "" echo "Provider running at http://localhost:3000 (PID $PROVIDER_PID)" +echo "Jaeger UI at http://localhost:16686" echo "" echo "Load extensions:" echo " Chrome: chrome://extensions → Load unpacked → $WALLET_PATH/dist/chrome/"