Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions .github/workflows/explorer-build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
name: Explorer Build

# Issue #520: "wire it into CI so it breaks loudly when the API changes."
# explorer-perf.yml already exercises explorer/ against a real testnet API,
# but only on a weekly schedule/manual dispatch, and it never actually runs
# `npm run build` — it discovered the explorer's build had been broken
# outright (a duplicated template block in one page, since fixed) only
# because someone ran `npm run build` by hand. This job runs the real
# production build on every push/PR that touches explorer/, so a build
# break (from an API/type change, a bad merge, or anything else) is a red
# CI check within minutes, not something waiting to be found by hand.

on:
push:
branches: [main, dev]
paths:
- "explorer/**"
- "sdk/typescript/**"
- ".github/workflows/explorer-build.yml"
pull_request:
paths:
- "explorer/**"
- "sdk/typescript/**"
- ".github/workflows/explorer-build.yml"

jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"

- name: Build the TypeScript SDK
working-directory: sdk/typescript
run: |
npm install
npm run build

# explorer/ depends on the SDK via a local `file:../sdk/typescript`
# dependency (the SDK is not yet published — see #517/#429, blocked on
# #512), so the SDK must be built before `npm install` in explorer/
# resolves and links it.
- name: Install explorer dependencies
working-directory: explorer
run: npm install --legacy-peer-deps

- name: Type-check
working-directory: explorer
# astro check has pre-existing, unrelated failures in
# scripts/a11y-test.ts and scripts/perf-test.ts (tracked
# separately) — this job checks the build, which is the specific
# "breaks loudly" signal #520 asks for; a full green astro check
# across the whole package is a separate concern.
run: npm run build

- name: Verify the built server actually starts
working-directory: explorer
env:
TRIDENT_TESTNET_API_URL: https://api.testnet.trident.dev
TRIDENT_MAINNET_API_URL: https://api.mainnet.trident.dev
EXPLORER_API_KEY: ci-smoke-test-key
PORT: 4321
run: |
node dist/server/entry.mjs &
SERVER_PID=$!
for i in $(seq 1 20); do
if curl --silent --fail --max-time 1 "http://127.0.0.1:4321/" >/dev/null 2>&1; then
echo "Server responded successfully"
kill "$SERVER_PID"
exit 0
fi
sleep 0.5
done
echo "Server did not respond within the timeout" >&2
kill "$SERVER_PID" 2>/dev/null || true
exit 1
5 changes: 4 additions & 1 deletion docs/runbooks/alerts.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ One section per alert in [`monitoring/alerts.yml`](../../monitoring/alerts.yml).
Each section covers what the alert means, why its threshold was picked, and
the first steps to take when it fires. See
[`docs/metrics-catalog.md`](../metrics-catalog.md) for what every metric
referenced here actually measures.
referenced here actually measures. Routing (which severity/service pages
whom) is configured in [`monitoring/alertmanager.yml`](../../monitoring/alertmanager.yml) —
"page on-call" below means whatever's wired into that file's
`on-call-critical`/`on-call-warning` receivers.

## TridentIndexerLagWarning

Expand Down
25 changes: 25 additions & 0 deletions explorer/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions explorer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"dependencies": {
"@astrojs/node": "^11.1.4",
"@astrojs/tailwind": "^6.0.2",
"@trident-indexer/sdk": "file:../sdk/typescript",
"astro": "^7.2.9",
"tailwindcss": "^3.4.13"
},
Expand Down
114 changes: 67 additions & 47 deletions explorer/src/lib/api.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,64 @@
// Issue #520: use a published SDK the way a real user would, not internal
// APIs. This module now goes through @trident-indexer/sdk's TridentClient
// (real retry/backoff, Zod response validation, typed errors) instead of a
// hand-rolled fetchWithTimeout — the SDK is not yet published to npm, so
// package.json references it via a local `file:` dependency
// (file:../sdk/typescript) until it is; swap that for a real version range
// once #517/#429 land a published release.
//
// The SDK's own types are camelCase (contractId, ledgerSequence, ...) to
// match its own conventions, while every .astro page in this app was
// written against the raw REST API's snake_case JSON shape (contract_id,
// ledger_sequence, ...). Translating at this one boundary — rather than
// migrating every field access across index.astro, contract/[address]/
// index.astro, and contract/[address]/event/[id].astro (including inline
// client-side <script> blocks that re-fetch this same shape from
// /api/events.json) — gets the real behavioral benefit (actual retry
// logic, actual response validation) without a large, higher-risk
// find-and-rename across presentation code this session can't visually
// verify rendered correctly.

import { TridentClient, type SorobanEvent as SdkSorobanEvent } from "@trident-indexer/sdk";
import type { SorobanEvent, ListEventsResponse, Network } from "./types";

const TESTNET_URL =
import.meta.env.TRIDENT_TESTNET_API_URL ?? "https://api.testnet.trident.dev";
const MAINNET_URL =
import.meta.env.TRIDENT_MAINNET_API_URL ?? "https://api.mainnet.trident.dev";
const API_KEY: string = import.meta.env.EXPLORER_API_KEY ?? "";
const API_TIMEOUT = 30000; // 30 second timeout

function baseUrl(network: Network): string {
function apiUrlFor(network: Network): string {
return network === "mainnet" ? MAINNET_URL : TESTNET_URL;
}

function authHeaders(): HeadersInit {
const h: Record<string, string> = {};
if (API_KEY) h["X-API-Key"] = API_KEY;
return h;
const clientCache = new Map<Network, TridentClient>();

function clientFor(network: Network): TridentClient {
const cached = clientCache.get(network);
if (cached) return cached;

const client = new TridentClient({
apiUrl: apiUrlFor(network),
apiKey: API_KEY || undefined,
network,
});
clientCache.set(network, client);
return client;
}

function toSnakeCaseEvent(event: SdkSorobanEvent): SorobanEvent {
return {
id: event.id,
contract_id: event.contractId,
ledger_sequence: event.ledgerSequence,
ledger_timestamp: event.ledgerTimestamp,
transaction_hash: event.transactionHash,
event_index: event.eventIndex,
event_type: event.eventType,
topics: event.topics,
data: typeof event.data === "string" ? event.data : JSON.stringify(event.data),
created_at: event.createdAt,
};
}

export interface QueryEventsParams {
Expand All @@ -27,57 +71,33 @@ export interface QueryEventsParams {
network?: Network;
}

async function fetchWithTimeout(
url: string,
options: RequestInit = {},
timeoutMs = API_TIMEOUT,
): Promise<Response> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);

try {
const res = await fetch(url, {
...options,
signal: controller.signal,
});
return res;
} finally {
clearTimeout(timeout);
}
}

export async function listEvents(
params: QueryEventsParams = {},
): Promise<ListEventsResponse> {
const network: Network = params.network ?? "testnet";
const url = new URL(`${baseUrl(network)}/v1/events`);
if (params.contractId) url.searchParams.set("contractId", params.contractId);
if (params.topic0) url.searchParams.set("topic0", params.topic0);
if (params.ledgerFrom != null)
url.searchParams.set("ledgerFrom", String(params.ledgerFrom));
if (params.ledgerTo != null)
url.searchParams.set("ledgerTo", String(params.ledgerTo));
if (params.cursor) url.searchParams.set("cursor", params.cursor);
url.searchParams.set("limit", String(params.limit ?? 25));
const client = clientFor(network);

const res = await fetchWithTimeout(url.toString(), {
headers: authHeaders(),
const result = await client.queryEvents({
contractId: params.contractId,
topic0: params.topic0,
ledgerFrom: params.ledgerFrom,
ledgerTo: params.ledgerTo,
after: params.cursor,
limit: params.limit ?? 25,
});
if (!res.ok) throw new Error(`API ${res.status}`);
return (await res.json()) as ListEventsResponse;

return {
events: result.events.map(toSnakeCaseEvent),
has_more: result.hasMore,
next_cursor: result.cursor,
};
}

export async function getEvent(
id: string,
network: Network = "testnet",
): Promise<SorobanEvent> {
const res = await fetchWithTimeout(
`${baseUrl(network)}/v1/events/${encodeURIComponent(id)}`,
{
headers: authHeaders(),
},
);
if (!res.ok) throw new Error(`API ${res.status}`);
const body = (await res.json()) as { event: SorobanEvent };
return body.event;
const client = clientFor(network);
const event = await client.getEventById({ id });
return toSnakeCaseEvent(event);
}
Loading