Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
f310bc8
DPS-77 - add health_endpoint_format column and OTel foundation types
dantheuber Mar 15, 2026
b6245be
DPS-78 - add TeamApiKeyStore interface, implementation, and tests
dantheuber Mar 15, 2026
828d80a
DPS-78 - add requireApiKeyAuth middleware with tests
dantheuber Mar 15, 2026
c3ed2b4
DPS-78 - add API key CRUD routes with audit logging
dantheuber Mar 15, 2026
429c643
DPS-79 - add OTLP JSON and Prometheus text parsers with tests
dantheuber Mar 15, 2026
9d51f34
DPS-80 - add OTLP receiver endpoint with auto-registration and rate l…
dantheuber Mar 15, 2026
9c912d0
DPS-81 - add format-aware parser dispatch and poller integration
dantheuber Mar 15, 2026
8f1a984
DPS-82 - add format configuration UI and API key management
dantheuber Mar 15, 2026
878aea1
DPS-docs-a - update specs for OTel and Prometheus source ingestion
dantheuber Mar 15, 2026
d1ffd13
DPS-docs-b/c - update README and .env.example for OTel sources
dantheuber Mar 15, 2026
e95f967
custom mappings for otlp/otel ingress
dantheuber Mar 15, 2026
3b4a639
manifest otlp/otel
dantheuber Mar 15, 2026
101112c
stats views of otlp pushers
dantheuber Mar 16, 2026
ecf2d78
DPS-84, DPS-85: add rate limit columns and usage buckets migrations
dantheuber Apr 5, 2026
282d6a2
DPS-86: add rate limit types, store methods, and auth extension
dantheuber Apr 5, 2026
fc5afbf
DPS-87: implement ApiKeyUsageStore for usage bucket persistence
dantheuber Apr 5, 2026
4f62c21
DPS-88, DPS-89: add per-key rate limit and usage tracking middleware
dantheuber Apr 5, 2026
67ccf04
DPS-90, DPS-91: wire retention pruning and middleware chain
dantheuber Apr 5, 2026
d2e0cdd
DPS-92, DPS-93: add team and admin rate limit and usage routes
dantheuber Apr 5, 2026
105b8a4
DPS-94: extend otlpStats endpoints with rate limit config and usage s…
dantheuber Apr 5, 2026
9c03524
DPS-95: add frontend types and API client for rate limits and usage
dantheuber Apr 5, 2026
5c2e363
DPS-96: add ApiKeyUsageChart component with time range selector
dantheuber Apr 5, 2026
84341e4
DPS-97: add rate limit column and edit dialog to ApiKeys.tsx
dantheuber Apr 5, 2026
36be0f5
DPS-98: update OtlpStats.tsx with usage summaries, rate limit control…
dantheuber Apr 5, 2026
018704c
DPS-99: update OtlpAdmin.tsx with usage overview, per-key graphs, and…
dantheuber Apr 5, 2026
0d8d64c
DPS-100: add unit tests for perKeyRateLimit, trackApiKeyUsage, ApiKey…
dantheuber Apr 5, 2026
a6465b1
DPS-101: add integration tests for rate limit/usage routes and OTLP r…
dantheuber Apr 5, 2026
0cfa753
DPS-102: add frontend tests for ApiKeyUsageChart, rate limit dialogs,…
dantheuber Apr 5, 2026
ae1097c
update specs and readme
dantheuber Apr 5, 2026
20249d2
fixes to login redirects and auth timeouts
dantheuber Apr 5, 2026
57d75e4
DPS-110i: add DiscoverySource, Span, ExternalNodeEnrichment types to …
dantheuber Apr 6, 2026
2eb762e
DPS-110a-f: add migrations 037-041 for trace discovery schema
dantheuber Apr 6, 2026
d48ee35
DPS-110g,h: add OTLP trace, histogram, and sum types to otlp-types.ts
dantheuber Apr 6, 2026
247f252
DPS-110j-p: add new stores, extend existing stores for trace discovery
dantheuber Apr 6, 2026
0dec095
DPS-110q,r,s: add tests for migrations 037-041, new stores, and store…
dantheuber Apr 6, 2026
a862f35
DPS-112a,f: add TraceParser service and tests for OTLP trace dependen…
dantheuber Apr 6, 2026
d5a8350
DPS-112b,g: add TraceDependencyBridge service and tests for trace-to-…
dantheuber Apr 6, 2026
80bee15
DPS-112c: extract findOrCreateService to shared otlpServiceResolver m…
dantheuber Apr 6, 2026
631c921
DPS-112d,e: add trace receiver route and mount at /v1/traces
dantheuber Apr 6, 2026
ee3c723
DPS-112h: add integration tests for /v1/traces endpoint
dantheuber Apr 6, 2026
d0fe250
DPS-111a-i: add histogram/sum metric processing with percentile latency
dantheuber Apr 6, 2026
9659d4c
DPS-113: add AutoAssociator for trace-discovered dependency auto-asso…
dantheuber Apr 6, 2026
17f49b6
DPS-114: add management API endpoints for auto-discovered dependencie…
dantheuber Apr 6, 2026
6b54c65
DPS-115: add graph integration for auto-discovered dependencies
dantheuber Apr 6, 2026
74ee084
DPS-116: add span retention cleanup and admin settings endpoint
dantheuber Apr 6, 2026
643fa01
DPS-83: update specs and README for trace discovery, histogram proces…
dantheuber Apr 6, 2026
8642264
fixes for eslint and tests
dantheuber Apr 7, 2026
a0b0e9b
test issue in cicd
dantheuber Apr 7, 2026
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
27 changes: 20 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,17 @@ For detailed deployment options (bare Node.js, reverse proxy, backups), see the
- Register services with health check endpoints and poll them on configurable intervals (5s to 1hr)
- Exponential backoff on failures with circuit breaker protection (opens after 10 consecutive failures)
- Custom schema mapping for non-standard health endpoints, including object-keyed formats (Spring Boot Actuator, ASP.NET Health Checks, etc.) with skipped-check support
- **OTLP push ingestion** — receive metrics via `POST /v1/metrics` and traces via `POST /v1/traces` from OpenTelemetry collectors with team-scoped API key authentication, auto-registration of unknown services, and per-service custom metric/attribute name mappings
- **OTLP trace-based dependency discovery** — automatically discover dependencies from CLIENT and PRODUCER spans, with full span storage for future trace timeline views, configurable retention (default 7 days), and auto-association to registered services
- **Histogram and sum metric processing** — extract percentile latency (p50/p95/p99) from OTLP histogram metrics and request counts from sum metrics via linear interpolation
- **Prometheus scraping** — poll Prometheus text exposition endpoints (`text/plain; version=0.0.4`) with automatic metric-to-dependency mapping and per-service custom metric/label name mappings
- Contact info and impact overrides with 3-tier merge hierarchy (instance > canonical > polled) — resolved in API responses
- Per-hostname concurrency limiting and request deduplication prevent polling abuse

**Visualization**
- Interactive dependency graph (React Flow) with team filtering, search, layout controls, automatic high-latency detection, and isolated tree view
- Visual distinction for auto-discovered vs manually-configured dependencies (dashed edges with "Suggested" badge for unconfirmed trace-discovered associations)
- Org-wide external node enrichment — add display names, descriptions, impact, and contact info to shared external dependencies (e.g., Stripe, PostgreSQL)
- Latency charts (min/avg/max over time) and health timeline swimlanes per dependency
- Edge selection shows per-dependency latency chart, contact info, impact, and error history
- Node selection shows aggregate latency chart across all dependents and merged contact info
Expand Down Expand Up @@ -77,8 +83,8 @@ For detailed deployment options (bare Node.js, reverse proxy, backups), see the

**Operations**
- SQLite database — zero external dependencies, sessions survive restarts
- Automatic data retention cleanup (configurable period, default 365 days)
- Runtime-configurable admin settings (retention, polling, rate limits, alerts)
- Automatic data retention cleanup (configurable period, default 365 days) with separate span retention (default 7 days, admin-configurable)
- Runtime-configurable admin settings (retention, span retention, polling, rate limits, alerts)
- Structured JSON logging in production via pino
- Docker image with health check and volume-mounted data

Expand Down Expand Up @@ -193,8 +199,10 @@ cp server/.env.example server/.env
| `SSL_CERT_PATH` | — | PEM certificate path (pair with `SSL_KEY_PATH`) |
| `SSL_KEY_PATH` | — | PEM private key path (pair with `SSL_CERT_PATH`) |
| `HTTP_PORT` | — | Plain HTTP port for health checks + redirect when `ENABLE_HTTPS=true` |
| `RATE_LIMIT_MAX` | `100` | Max requests per IP per 15-minute window |
| `AUTH_RATE_LIMIT_MAX` | `10` | Max auth requests per IP per minute |
| `RATE_LIMIT_MAX` | `3000` | Max requests per IP per minute (global) |
| `AUTH_RATE_LIMIT_MAX` | `20` | Max auth requests per IP per minute |
| `OTLP_RATE_LIMIT_MAX` | `600` | Max OTLP requests per IP per minute (global) |
| `OTLP_PER_KEY_RATE_LIMIT_RPM` | `150000` | Default per-API-key rate limit (requests/minute) |

**Operations:**

Expand Down Expand Up @@ -308,23 +316,28 @@ All endpoints require authentication unless noted. Admin endpoints require the a
| Users | CRUD on `/api/users` (admin), `POST` and `PUT /:id/password` (local auth) |
| Aliases | CRUD on `/api/aliases` (admin for mutations), `GET /canonical-names` |
| Overrides | `GET/PUT/DELETE /api/canonical-overrides/:name`, `PUT/DELETE /api/dependencies/:id/overrides` |
| Associations | CRUD on `/api/dependencies/:id/associations` |
| Associations | CRUD on `/api/dependencies/:id/associations`, `PUT /:assocId/confirm`, `PUT /:assocId/dismiss` |
| Discovered Deps | `GET /api/services/:id/dependencies/discovered`, `PATCH /api/dependencies/:id/enrich` |
| External Nodes | `GET/PUT/DELETE /api/external-nodes/:canonicalName` |
| Graph | `GET /api/graph` with `team`, `service`, `dependency` filters |
| History | `GET /api/latency/:id` + `/buckets`, `GET /api/errors/:id`, `GET /api/dependencies/:id/timeline`, `GET /api/services/:id/poll-history` |
| Admin | `GET/PUT /api/admin/settings`, `GET /api/admin/audit-log` |
| Admin | `GET/PUT /api/admin/settings`, `GET/PUT /api/admin/settings/span-retention`, `GET /api/admin/audit-log` |
| Manifest | `GET/POST /api/teams/:id/manifests`, `GET/PUT/DELETE /:id/manifests/:configId`, `POST /:id/manifests/sync`, `POST /:id/manifests/:configId/sync`, `GET /:id/manifests/:configId/sync-history`, `POST /api/manifest/validate`, `POST /api/manifest/test-url` |
| Drift Flags | `GET /api/teams/:id/drifts` + `/summary`, `PUT /:driftId/accept` + `/dismiss` + `/reopen`, `POST /bulk-accept` + `/bulk-dismiss` |
| Catalog | `GET /api/catalog/external-dependencies` — canonical name registry with team usage, descriptions, and aliases |
| Alerts | CRUD on `/api/teams/:id/alert-channels` + `/test`, `GET/PUT /:id/alert-rules`, `GET /:id/alert-history`, `GET/POST /:id/alert-mutes`, `DELETE /:id/alert-mutes/:muteId`, `GET /api/admin/alert-mutes` |
| OTLP | `POST /v1/metrics`, `POST /v1/traces` (API key auth, OTLP JSON) |
| API Keys | `GET/POST /api/teams/:id/api-keys`, `DELETE /:id/api-keys/:keyId` |

## Security

Depsera includes defense-in-depth security:

- **Security headers** via Helmet (CSP, HSTS, X-Frame-Options, X-Content-Type-Options)
- **API key authentication** for OTLP push endpoints — team-scoped keys with SHA-256 hashing, prefix display, and last-used tracking
- **SSRF protection** on health endpoints with private IP blocking and DNS rebinding prevention; configurable allowlist for internal networks
- **CSRF protection** via double-submit cookie pattern
- **Rate limiting** — global (100 req/15min) and auth-specific (10 req/min) per IP
- **Rate limiting** — global (3000 req/min), auth-specific (20 req/min), OTLP global (600 req/min per IP), and per-API-key token bucket (150k req/min default)
- **Session secret validation** — production startup refuses weak or missing secrets
- **Redirect validation** prevents open redirect attacks on logout
- **Body size limit** (100KB) on JSON payloads
Expand Down
4 changes: 2 additions & 2 deletions client/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,12 @@ describe('App', () => {
expect(await screen.findByText('Sign in to continue')).toBeInTheDocument();
});

it('shows the dashboard title on login page', async () => {
it('shows the logo on login page', async () => {
mockFetch
.mockResolvedValueOnce({ ok: false, status: 401, json: () => Promise.resolve({}) })
.mockResolvedValueOnce({ ok: true, status: 200, json: () => Promise.resolve({ mode: 'oidc' }) });

renderApp();
expect(await screen.findByRole('heading', { name: 'Depsera' })).toBeInTheDocument();
expect(await screen.findByAltText('Depsera')).toBeInTheDocument();
});
});
9 changes: 9 additions & 0 deletions client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import ServiceCatalog from './components/pages/Catalog/ServiceCatalog';
import ManifestPage from './components/pages/Manifest/ManifestPage';
import ManifestAdmin from './components/pages/Admin/ManifestAdmin';
import AlertMutesAdmin from './components/pages/Admin/AlertMutesAdmin';
import OtlpAdmin from './components/pages/Admin/OtlpAdmin';

function App() {
return (
Expand Down Expand Up @@ -80,6 +81,14 @@ function App() {
</ProtectedRoute>
}
/>
<Route
path="admin/otlp"
element={
<ProtectedRoute requireAdmin>
<OtlpAdmin />
</ProtectedRoute>
}
/>
</Route>
<Route path="*" element={<NotFound />} />
</Routes>
Expand Down
50 changes: 50 additions & 0 deletions client/src/api/apiKeys.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { handleResponse } from './common';
import { withCsrfToken } from './csrf';

export interface ApiKey {
id: string;
team_id: string;
name: string;
key_prefix: string;
last_used_at: string | null;
created_at: string;
created_by: string;
rate_limit_rpm: number | null;
rate_limit_admin_locked: number;
}

export interface ApiKeyWithRawKey extends ApiKey {
rawKey: string;
}

export async function listApiKeys(teamId: string): Promise<ApiKey[]> {
const response = await fetch(`/api/teams/${teamId}/api-keys`, {
credentials: 'include',
});
return handleResponse<ApiKey[]>(response);
}

export async function createApiKey(
teamId: string,
name: string
): Promise<ApiKeyWithRawKey> {
const response = await fetch(`/api/teams/${teamId}/api-keys`, {
method: 'POST',
headers: withCsrfToken({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ name }),
credentials: 'include',
});
return handleResponse<ApiKeyWithRawKey>(response);
}

export async function deleteApiKey(teamId: string, keyId: string): Promise<void> {
const response = await fetch(`/api/teams/${teamId}/api-keys/${keyId}`, {
method: 'DELETE',
headers: withCsrfToken(),
credentials: 'include',
});
if (!response.ok) {
const error = await response.json().catch(() => ({ message: 'Delete failed' }));
throw new Error(error.message || error.error || `HTTP error ${response.status}`);
}
}
22 changes: 22 additions & 0 deletions client/src/api/associations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,25 @@ export async function deleteAssociation(
throw new Error(error.message || `HTTP error ${response.status}`);
}
}

export async function confirmAssociation(
depId: string,
assocId: string,
): Promise<{ success: boolean }> {
const response = await fetch(
`/api/dependencies/${depId}/associations/${assocId}/confirm`,
{ method: 'PUT', headers: withCsrfToken(), credentials: 'include' },
);
return handleResponse<{ success: boolean }>(response);
}

export async function dismissAssociation(
depId: string,
assocId: string,
): Promise<{ success: boolean }> {
const response = await fetch(
`/api/dependencies/${depId}/associations/${assocId}/dismiss`,
{ method: 'PUT', headers: withCsrfToken(), credentials: 'include' },
);
return handleResponse<{ success: boolean }>(response);
}
71 changes: 71 additions & 0 deletions client/src/api/common.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { handleResponse } from './common';

function mockResponse(body: unknown, status = 200): Response {
return {
ok: status >= 200 && status < 300,
status,
json: () => Promise.resolve(body),
} as Response;
}

describe('handleResponse', () => {
it('returns parsed JSON on success', async () => {
const data = { id: '1', name: 'Test' };
const result = await handleResponse(mockResponse(data));
expect(result).toEqual(data);
});

it('throws error with message from response body', async () => {
await expect(
handleResponse(mockResponse({ message: 'Not found' }, 404)),
).rejects.toThrow('Not found');
});

it('throws error with error field from response body', async () => {
await expect(
handleResponse(mockResponse({ error: 'Bad request' }, 400)),
).rejects.toThrow('Bad request');
});

it('throws generic error when response body has no message', async () => {
await expect(
handleResponse(mockResponse({}, 500)),
).rejects.toThrow('HTTP error 500');
});

it('throws fallback error when response body is not JSON', async () => {
const response = {
ok: false,
status: 500,
json: () => Promise.reject(new Error('not JSON')),
} as Response;

await expect(handleResponse(response)).rejects.toThrow('Request failed');
});

it('dispatches auth:expired event on 401 response', async () => {
const handler = jest.fn();
window.addEventListener('auth:expired', handler);

await expect(
handleResponse(mockResponse({ error: 'Not authenticated' }, 401)),
).rejects.toThrow('Not authenticated');

expect(handler).toHaveBeenCalledTimes(1);

window.removeEventListener('auth:expired', handler);
});

it('does not dispatch auth:expired event on non-401 errors', async () => {
const handler = jest.fn();
window.addEventListener('auth:expired', handler);

await expect(
handleResponse(mockResponse({ message: 'Server error' }, 500)),
).rejects.toThrow('Server error');

expect(handler).not.toHaveBeenCalled();

window.removeEventListener('auth:expired', handler);
});
});
3 changes: 3 additions & 0 deletions client/src/api/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
*/
export async function handleResponse<T>(response: Response): Promise<T> {
if (!response.ok) {
if (response.status === 401) {
window.dispatchEvent(new Event('auth:expired'));
}
const error = await response.json().catch(() => ({ message: 'Request failed' }));
throw new Error(error.message || error.error || `HTTP error ${response.status}`);
}
Expand Down
28 changes: 28 additions & 0 deletions client/src/api/dependencies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,31 @@ export async function clearDependencyOverrides(id: string): Promise<void> {
throw new Error(error.message || `HTTP error ${response.status}`);
}
}

export interface DependencyEnrichmentInput {
displayName?: string | null;
description?: string | null;
impact?: string | null;
}

export async function enrichDependency(
id: string,
input: DependencyEnrichmentInput,
): Promise<Dependency> {
const response = await fetch(`/api/dependencies/${id}/enrich`, {
method: 'PATCH',
headers: withCsrfToken({ 'Content-Type': 'application/json' }),
body: JSON.stringify(input),
credentials: 'include',
});
return handleResponse<Dependency>(response);
}

export async function listDiscoveredDependencies(
serviceId: string,
): Promise<Dependency[]> {
const response = await fetch(`/api/services/${serviceId}/discovered-dependencies`, {
credentials: 'include',
});
return handleResponse<Dependency[]>(response);
}
55 changes: 55 additions & 0 deletions client/src/api/externalNodes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { handleResponse } from './common';
import { withCsrfToken } from './csrf';

export interface ExternalNodeEnrichment {
id: string;
canonical_name: string;
display_name: string | null;
description: string | null;
impact: string | null;
contact: string | null;
service_type: string | null;
created_at: string;
updated_at: string;
updated_by: string | null;
}

export interface UpsertExternalNodeInput {
displayName?: string | null;
description?: string | null;
impact?: string | null;
contact?: Record<string, unknown> | null;
serviceType?: string | null;
}

export async function fetchExternalNodes(): Promise<ExternalNodeEnrichment[]> {
const response = await fetch('/api/external-nodes', {
credentials: 'include',
});
return handleResponse<ExternalNodeEnrichment[]>(response);
}

export async function upsertExternalNode(
canonicalName: string,
input: UpsertExternalNodeInput,
): Promise<ExternalNodeEnrichment> {
const response = await fetch(`/api/external-nodes/${encodeURIComponent(canonicalName)}`, {
method: 'PUT',
headers: withCsrfToken({ 'Content-Type': 'application/json' }),
body: JSON.stringify(input),
credentials: 'include',
});
return handleResponse<ExternalNodeEnrichment>(response);
}

export async function deleteExternalNode(canonicalName: string): Promise<void> {
const response = await fetch(`/api/external-nodes/${encodeURIComponent(canonicalName)}`, {
method: 'DELETE',
headers: withCsrfToken(),
credentials: 'include',
});
if (!response.ok) {
const error = await response.json().catch(() => ({ message: 'Delete failed' }));
throw new Error(error.message || `HTTP error ${response.status}`);
}
}
Loading
Loading