From 13d83022f8d4ad10b542fda05298cbb297b2eb95 Mon Sep 17 00:00:00 2001 From: Gorka Date: Thu, 12 Mar 2026 14:01:32 -0300 Subject: [PATCH 1/6] feat: add Jaeger to local infrastructure Adds Jaeger all-in-one container to docker-compose with OTLP HTTP receiver on port 4318 and UI on port 16686. Includes healthcheck, readiness poll in up.sh, and log routing to jaeger.log. down.sh cleans up Jaeger logs on teardown. --- down.sh | 7 ++++++ e2e/docker-compose.yml | 28 +++++++++++++++++++++++ up.sh | 51 +++++++++++++++++++++++++++++++++++------- 3 files changed, 78 insertions(+), 8 deletions(-) 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/docker-compose.yml b/e2e/docker-compose.yml index fd6b082..38a6452 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", "curl -sf 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/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/" From 944c6bf2187e11627df0eba88113682b167f50f3 Mon Sep 17 00:00:00 2001 From: Gorka Date: Thu, 12 Mar 2026 14:01:43 -0300 Subject: [PATCH 2/6] feat: add OpenTelemetry instrumentation to E2E test Adds OTel adapter (tracer.ts) implementing MoonlightTracer with withActiveSpan for W3C traceparent propagation. All E2E steps wrapped in withE2ESpan for distributed tracing. SDK tracer passed through to deposit, receive, send, and withdraw flows. Trace IDs collected and written to e2e-trace-ids.json for verify-otel consumption. Includes 429 rate limit retry logic in bundle submission. --- e2e/.gitignore | 1 + e2e/account.ts | 4 ++ e2e/auth.ts | 63 ++++++++++++++++-------------- e2e/bundle.ts | 100 ++++++++++++++++++++++++++++-------------------- e2e/deno.json | 21 ++++++++-- e2e/deno.lock | 38 ++++++++++-------- e2e/deposit.ts | 5 ++- e2e/main.ts | 38 +++++++++++++----- e2e/receive.ts | 5 ++- e2e/send.ts | 5 ++- e2e/tracer.ts | 69 +++++++++++++++++++++++++++++++++ e2e/withdraw.ts | 5 ++- 12 files changed, 247 insertions(+), 107 deletions(-) create mode 100644 e2e/tracer.ts 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/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..78cb26b 100644 --- a/e2e/deno.json +++ b/e2e/deno.json @@ -1,11 +1,26 @@ { "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", + "@moonlight/moonlight-sdk": "../../moonlight-sdk/mod.ts", "@colibri/core": "jsr:@colibri/core@^0.16.1", - "stellar-sdk": "npm:@stellar/stellar-sdk@14.2.0" + "@opentelemetry/api": "npm:@opentelemetry/api@^1.9.0", + "stellar-sdk": "npm:@stellar/stellar-sdk@14.2.0", + "@stellar/stellar-sdk": "npm:@stellar/stellar-sdk@^14.2.0", + "@stellar/stellar-sdk/contract": "npm:@stellar/stellar-sdk@^14.2.0/contract", + "@fifo/convee": "jsr:@fifo/convee@^0.5.0", + "@noble/curves": "jsr:@noble/curves@^1.8.0", + "@noble/curves/p256": "jsr:@noble/curves@^1.8.0/p256", + "@noble/curves/abstract/modular": "jsr:@noble/curves@^1.8.0/abstract/modular", + "@noble/hashes": "jsr:@noble/hashes@^1.6.1", + "@noble/hashes/sha256": "jsr:@noble/hashes@^1.6.1/sha256", + "@noble/hashes/hkdf": "jsr:@noble/hashes@^1.6.1/hkdf", + "@noble/secp256k1": "jsr:@noble/secp256k1", + "tslib": "npm:tslib@2.5.0", + "buffer": "npm:buffer@6.0.3", + "asn1js": "npm:asn1js@3.0.5" } } 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/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/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( From 5d300b52002ede4dd0177fe8ee46a082e150fc96 Mon Sep 17 00:00:00 2001 From: Gorka Date: Thu, 12 Mar 2026 14:01:55 -0300 Subject: [PATCH 3/6] feat: add OTEL verification test Validates OpenTelemetry traces in Jaeger after E2E run with 16 checks: provider function-level spans, auth pipeline, bundle processing, background services, SDK step/auth/bundle/channel/account spans, and distributed tracing (shared trace IDs + CHILD_OF references). Uses hybrid query strategy: trace-by-ID for SDK and distributed tracing checks, time-windowed service query for provider app spans. --- e2e/verify-otel.ts | 416 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 416 insertions(+) create mode 100644 e2e/verify-otel.ts 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); +}); From a46a87c589bc09a26d1aeaeac003f4d8fc9264ae Mon Sep 17 00:00:00 2001 From: Gorka Date: Thu, 12 Mar 2026 15:05:42 -0300 Subject: [PATCH 4/6] fix: use wget instead of curl for Jaeger healthcheck The Jaeger Alpine image doesn't include curl. Switch to wget which is available by default. --- e2e/docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml index 38a6452..e61599c 100644 --- a/e2e/docker-compose.yml +++ b/e2e/docker-compose.yml @@ -8,7 +8,7 @@ services: environment: COLLECTOR_OTLP_ENABLED: "true" healthcheck: - test: ["CMD-SHELL", "curl -sf http://localhost:16686/api/services || exit 1"] + test: ["CMD-SHELL", "wget -qO- http://localhost:16686/api/services || exit 1"] interval: 2s timeout: 3s retries: 15 From ace64d953f9149310cb1ccb4e4419332985bca57 Mon Sep 17 00:00:00 2001 From: Gorka Date: Thu, 12 Mar 2026 14:22:34 -0300 Subject: [PATCH 5/6] docs: add OTEL/Jaeger documentation - Add step-by-step Jaeger UI navigation guide to e2e/README.md - Add Jaeger to architecture diagram and services list - Update repo README with Jaeger stage, verify-otel command, and troubleshooting --- README.md | 26 +++++--- e2e/README.md | 169 ++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 182 insertions(+), 13 deletions(-) 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/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) From 15e347205a5cd0dc7e566224f9de2082b2869de4 Mon Sep 17 00:00:00 2001 From: Gorka Date: Thu, 12 Mar 2026 14:19:40 -0300 Subject: [PATCH 6/6] chore: switch SDK import from local path to JSR Replaces local path import (../../moonlight-sdk/mod.ts) with jsr:@moonlight/moonlight-sdk@^0.7.0 and removes transitive dependency entries that JSR resolves automatically. Requires SDK 0.7.0+ to be published with tracing support. --- e2e/deno.json | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/e2e/deno.json b/e2e/deno.json index 78cb26b..febd8ab 100644 --- a/e2e/deno.json +++ b/e2e/deno.json @@ -5,22 +5,9 @@ "verify-otel": "deno run --allow-all verify-otel.ts" }, "imports": { - "@moonlight/moonlight-sdk": "../../moonlight-sdk/mod.ts", "@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", - "@stellar/stellar-sdk": "npm:@stellar/stellar-sdk@^14.2.0", - "@stellar/stellar-sdk/contract": "npm:@stellar/stellar-sdk@^14.2.0/contract", - "@fifo/convee": "jsr:@fifo/convee@^0.5.0", - "@noble/curves": "jsr:@noble/curves@^1.8.0", - "@noble/curves/p256": "jsr:@noble/curves@^1.8.0/p256", - "@noble/curves/abstract/modular": "jsr:@noble/curves@^1.8.0/abstract/modular", - "@noble/hashes": "jsr:@noble/hashes@^1.6.1", - "@noble/hashes/sha256": "jsr:@noble/hashes@^1.6.1/sha256", - "@noble/hashes/hkdf": "jsr:@noble/hashes@^1.6.1/hkdf", - "@noble/secp256k1": "jsr:@noble/secp256k1", - "tslib": "npm:tslib@2.5.0", - "buffer": "npm:buffer@6.0.3", - "asn1js": "npm:asn1js@3.0.5" + "stellar-sdk": "npm:@stellar/stellar-sdk@14.2.0" } }