Documentation Index
Fetch the complete documentation index at: https://langwatch.ai/docs/llms.txt
Use this file to discover all available pages before exploring further.
Goal: catch misconfigurations in the gateway BEFORE they surface during an on-call incident. This recipe provisions a temporary virtual key, calls /v1/chat/completions, verifies the response and its trace headers, and cleans up — all from a CI job using only the langwatch CLI and curl.
Runtime: typically 3–8 seconds wall-clock. One gateway request + two REST round-trips.
What it validates
- The public REST API at
/api/gateway/v1/* is reachable with the CI token.
- Your
gpc_* provider binding is healthy (reach actual upstream).
- VK creation + secret reveal flow works.
- VK resolves end-to-end against the gateway data plane.
- Response carries
X-LangWatch-Trace-Id, X-LangWatch-Span-Id, X-LangWatch-Request-Id, and traceparent.
- VK revocation propagates (best-effort — cache TTL applies).
Prerequisites
- A CI-scoped API token stored in
CI_LANGWATCH_TOKEN with scopes virtualKeys:create, virtualKeys:delete, gatewayProviders:view.
- A healthy provider binding id in
CI_GPC_ID (e.g. gpc_01HZX...). Mint it manually once in the LangWatch UI → Gateway → Providers or via langwatch gateway-providers create.
jq available on the runner.
The script
#!/usr/bin/env bash
# ci-smoke-test.sh
set -euo pipefail
export LANGWATCH_API_KEY="$CI_LANGWATCH_TOKEN"
GATEWAY_URL="${LANGWATCH_GATEWAY_URL:-https://gateway.langwatch.ai}"
TIMESTAMP=$(date -u +%Y%m%dT%H%M%S)
cleanup() {
if [ -n "${VK_ID:-}" ]; then
echo "Revoking $VK_ID..."
langwatch virtual-keys revoke "$VK_ID" --format json >/dev/null || true
fi
}
trap cleanup EXIT
# 1. Mint short-lived VK
echo "Creating CI VK..."
CREATE=$(langwatch virtual-keys create \
--name "ci-smoke-$TIMESTAMP" \
--env test \
--provider "$CI_GPC_ID" \
--format json)
VK_ID=$(echo "$CREATE" | jq -r '.virtual_key.id')
VK_SECRET=$(echo "$CREATE" | jq -r '.secret')
[ -z "$VK_ID" ] && { echo "FAIL: empty VK id"; exit 1; }
[ -z "$VK_SECRET" ] && { echo "FAIL: empty secret"; exit 1; }
# 2. Call the gateway
echo "Calling $GATEWAY_URL/v1/chat/completions..."
TRACEPARENT="00-$(openssl rand -hex 16)-$(openssl rand -hex 8)-01"
RESPONSE_FILE=$(mktemp)
HEADERS_FILE=$(mktemp)
curl --fail-with-body --max-time 10 \
-H "Authorization: Bearer $VK_SECRET" \
-H "Content-Type: application/json" \
-H "traceparent: $TRACEPARENT" \
-D "$HEADERS_FILE" -o "$RESPONSE_FILE" \
-X POST "$GATEWAY_URL/v1/chat/completions" \
-d '{"model":"gpt-5-mini","messages":[{"role":"user","content":"ping"}],"max_tokens":4}'
# 3. Verify headers
REQUEST_ID=$(grep -i '^x-langwatch-request-id:' "$HEADERS_FILE" | awk '{print $2}' | tr -d '\r')
RESP_TRACE_ID=$(grep -i '^x-langwatch-trace-id:' "$HEADERS_FILE" | awk '{print $2}' | tr -d '\r')
RESP_SPAN_ID=$(grep -i '^x-langwatch-span-id:' "$HEADERS_FILE" | awk '{print $2}' | tr -d '\r')
RESP_TRACEPARENT=$(grep -i '^traceparent:' "$HEADERS_FILE" | awk '{print $2}' | tr -d '\r')
[ -z "$REQUEST_ID" ] && { echo "FAIL: missing X-LangWatch-Request-Id"; exit 1; }
[ ${#RESP_TRACE_ID} -ne 32 ] && { echo "FAIL: trace-id not 32 hex ($RESP_TRACE_ID)"; exit 1; }
[ ${#RESP_SPAN_ID} -ne 16 ] && { echo "FAIL: span-id not 16 hex ($RESP_SPAN_ID)"; exit 1; }
# Trace id should match the incoming traceparent's trace id (chars 4..36)
EXPECTED_TRACE_ID=${TRACEPARENT:3:32}
if [ "$RESP_TRACE_ID" != "$EXPECTED_TRACE_ID" ]; then
echo "FAIL: trace propagation — expected $EXPECTED_TRACE_ID, got $RESP_TRACE_ID"
exit 1
fi
# Response traceparent should carry the same trace-id onward
if [[ "$RESP_TRACEPARENT" != "00-$EXPECTED_TRACE_ID-"* ]]; then
echo "FAIL: response traceparent does not carry incoming trace-id"
exit 1
fi
# 4. Verify payload
CONTENT=$(jq -r '.choices[0].message.content' "$RESPONSE_FILE")
[ -z "$CONTENT" ] || [ "$CONTENT" = "null" ] && { echo "FAIL: empty content"; exit 1; }
echo "OK — smoke test passed (request_id=$REQUEST_ID, trace=$RESP_TRACE_ID)"
GitHub Actions
# .github/workflows/gateway-smoke.yml
name: Gateway smoke test
on:
schedule:
- cron: '*/15 * * * *' # every 15 minutes
workflow_dispatch:
jobs:
smoke:
runs-on: ubuntu-latest
timeout-minutes: 2
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm i -g langwatch
- run: bash ci-smoke-test.sh
env:
CI_LANGWATCH_TOKEN: ${{ secrets.CI_LANGWATCH_TOKEN }}
CI_GPC_ID: ${{ vars.CI_GPC_ID }}
LANGWATCH_GATEWAY_URL: https://gateway.langwatch.ai
Interpreting failures
| Failure | What’s broken | Where to look |
|---|
FAIL: empty VK id | REST API returning malformed response | Check GET /api/gateway/v1/virtual-keys manually; examine control-plane logs |
curl ... exit 7 | Gateway unreachable | Check the LB + DNS; nslookup gateway.langwatch.ai |
curl ... 401 | VK secret didn’t resolve at the gateway | Auth cache bootstrapped against a stale /changes feed; check control-plane-to-gateway HMAC agreement |
curl ... 403 model_not_allowed | VK’s provider binding doesn’t expose gpt-5-mini | Replace the model in the script with one your $CI_GPC_ID actually supports |
FAIL: trace propagation | Per-tenant OTel middleware is off | See Observability → trace-id handshake |
FAIL: empty content | Upstream returned no content — provider outage | Compare to raw response body saved in $RESPONSE_FILE |
Running it against self-hosted
export LANGWATCH_ENDPOINT="https://langwatch.your-corp.internal"
export LANGWATCH_GATEWAY_URL="https://gateway.your-corp.internal"
bash ci-smoke-test.sh
Both URLs can be the same hostname if your LB routes /api/* to the control plane and /v1/* to the gateway fleet.
Alerting
Pipe the script output into your alerting channel on failure. With PagerDuty:
- name: Alert on failure
if: failure()
run: |
curl -X POST https://events.pagerduty.com/v2/enqueue \
-d '{"routing_key":"${{ secrets.PD_KEY }}","event_action":"trigger","payload":{"summary":"Gateway smoke test failed","severity":"error","source":"github-actions"}}'
Run this test at a higher frequency than your /readyz scrapes — it exercises the full hot path (auth cache → bifrost → upstream → debit enqueue → trace export) whereas /readyz only checks internal state.
See also