Metrics: connect Prometheus or an OpenTelemetry service in words #46
No files matched your search
@@ -17,6 +17,7 @@ import { DynamicForm } from '@/components/forms/DynamicForm';
|
||||
import { DynamicViewPage } from '@/components/views/DynamicViewPage';
|
||||
import { LoadingFallback } from '@/components/common/LoadingFallback';
|
||||
import { LegacyProtocolsBanner } from '@/features/hardening/LegacyProtocolsBanner';
|
||||
import { MetricsPage } from '@/features/metricsConnect/MetricsPage';
|
||||
import { AlertsPage } from '@/features/alerts/AlertsPage';
|
||||
import { ReportsPage } from '@/features/reports/ReportsPage';
|
||||
import { RetentionPage } from '@/features/retention/RetentionPage';
|
||||
@@ -269,6 +270,10 @@ function renderView(schema: Schema | null, viewName?: string, id?: string, secti
|
||||
) {
|
||||
return <ReportsPage viewName={viewName} />;
|
||||
}
|
||||
// inbuxa: the metrics pages open with where the numbers go (settings-reorg).
|
||||
if (resolved.objectName === 'x:Metrics') {
|
||||
return <MetricsPage viewName={viewName} />;
|
||||
}
|
||||
if (resolved.objectName === 'x:Security') {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* inbuxa: Monitoring › Metrics, where the server's numbers go
|
||||
* (settings-reorg, second wave): Prometheus scraping, with the address and
|
||||
* a scrape job to paste, and pushing to an OpenTelemetry endpoint, with
|
||||
* Honeycomb, Grafana Cloud or your own collector filled in.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Check, Copy, Loader2, Radio } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { getApiBaseUrl } from '@/services/api';
|
||||
import { getAccountId, jmapGet, jmapSet } from '@/services/jmap/client';
|
||||
import { useAccountStore } from '@/stores/accountStore';
|
||||
import type { JmapSetResponse } from '@/types/jmap';
|
||||
import { toast } from '@/hooks/use-toast';
|
||||
import {
|
||||
newPassword,
|
||||
otelValue,
|
||||
providerOf,
|
||||
scrapeConfig,
|
||||
scrapeUrl,
|
||||
type OtelInput,
|
||||
type OtelProvider,
|
||||
} from './connect';
|
||||
|
||||
const OBJECT = 'x:Metrics';
|
||||
|
||||
function CopyBlock({ text }: { text: string }) {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = useState(false);
|
||||
return (
|
||||
<div className="relative">
|
||||
<pre className="overflow-x-auto rounded-lg border bg-muted/40 p-3 font-mono text-xs">{text}</pre>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="absolute right-1 top-1 h-7 gap-1 px-2 text-xs"
|
||||
onClick={() =>
|
||||
void navigator.clipboard.writeText(text).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
})
|
||||
}
|
||||
>
|
||||
{copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
|
||||
{copied ? t('metricsConnect.copied', 'Copied') : t('metricsConnect.copy', 'Copy')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MetricsConnect({ onSaved }: { onSaved?: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const canUpdate = useAccountStore((s) => s.hasObjectPermission('sysMetrics', 'Update'));
|
||||
const base = getApiBaseUrl() || window.location.origin;
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [promOn, setPromOn] = useState(false);
|
||||
const [promUser, setPromUser] = useState('prometheus');
|
||||
const [promPassword, setPromPassword] = useState<string | null>(null);
|
||||
// The new password stays on screen for copying after it's saved.
|
||||
const [passwordSaved, setPasswordSaved] = useState(false);
|
||||
const [promSaved, setPromSaved] = useState<{ on: boolean; user: string } | null>(null);
|
||||
const [provider, setProvider] = useState<OtelProvider>('off');
|
||||
const [savedProvider, setSavedProvider] = useState<OtelProvider>('off');
|
||||
const [otel, setOtel] = useState<OtelInput>({ endpoint: '', key: '', extra: '' });
|
||||
const [otelDirty, setOtelDirty] = useState(false);
|
||||
// Honeycomb's key travels as a plain header the server returns, so an empty field can keep it.
|
||||
const [savedHcKey, setSavedHcKey] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [check, setCheck] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
jmapGet(OBJECT, getAccountId(OBJECT), ['singleton'], ['prometheus', 'openTelemetry'])
|
||||
.then(([res]) => {
|
||||
const data = ((res?.[1] as { list?: Record<string, unknown>[] })?.list ?? [])[0] ?? {};
|
||||
if (!live) return;
|
||||
const prom = (data.prometheus ?? {}) as Record<string, unknown>;
|
||||
const on = prom['@type'] === 'Enabled';
|
||||
const user = (prom.authUsername as string | null) ?? '';
|
||||
setPromOn(on);
|
||||
if (on) setPromUser(user);
|
||||
setPromSaved({ on, user });
|
||||
const o = (data.openTelemetry ?? {}) as Record<string, unknown>;
|
||||
const p = providerOf(o);
|
||||
setProvider(p);
|
||||
setSavedProvider(p);
|
||||
const headers = (o.httpHeaders ?? {}) as Record<string, string>;
|
||||
const auth = (o.httpAuth ?? {}) as Record<string, unknown>;
|
||||
setSavedHcKey(p === 'honeycomb' ? (headers['x-honeycomb-team'] ?? '') : '');
|
||||
setOtel({
|
||||
endpoint: String(o.endpoint ?? ''),
|
||||
key: '',
|
||||
extra: p === 'honeycomb' ? (headers['x-honeycomb-dataset'] ?? '') : String(auth.username ?? ''),
|
||||
});
|
||||
setLoaded(true);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!loaded || !promSaved) return null;
|
||||
|
||||
const newPasswordPending = promPassword !== null && !passwordSaved;
|
||||
const promChanged = promOn !== promSaved.on || (promOn && (promUser !== promSaved.user || newPasswordPending));
|
||||
const otelChanged = provider !== savedProvider || otelDirty;
|
||||
// Grafana's token is a secret the server never hands back, so any change there asks for it again.
|
||||
const needsKey =
|
||||
(provider === 'honeycomb' && !(savedProvider === 'honeycomb' && savedHcKey)) ||
|
||||
(provider === 'grafana' && (provider !== savedProvider || otelDirty));
|
||||
const otelValid =
|
||||
provider === 'off' ||
|
||||
((provider === 'honeycomb' || /^https?:\/\//.test(otel.endpoint.trim())) && (!needsKey || otel.key.trim() !== ''));
|
||||
const promValid = !promOn || promUser.trim() === '' || promSaved.user === promUser.trim() || newPasswordPending;
|
||||
|
||||
const save = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const patch: Record<string, unknown> = {};
|
||||
if (promChanged) {
|
||||
patch.prometheus = promOn
|
||||
? {
|
||||
'@type': 'Enabled',
|
||||
authUsername: promUser.trim() || null,
|
||||
...(promUser.trim() === ''
|
||||
? { authSecret: { '@type': 'None' } }
|
||||
: newPasswordPending
|
||||
? { authSecret: { '@type': 'Value', secret: promPassword } }
|
||||
: {}),
|
||||
}
|
||||
: { '@type': 'Disabled' };
|
||||
}
|
||||
if (otelChanged) {
|
||||
const key = provider === 'honeycomb' && !otel.key.trim() ? savedHcKey : otel.key;
|
||||
patch.openTelemetry = otelValue(provider, { ...otel, key });
|
||||
}
|
||||
const [res] = await jmapSet(OBJECT, getAccountId(OBJECT), { update: { singleton: patch } });
|
||||
const body = res?.[1] as unknown as JmapSetResponse | undefined;
|
||||
const bad = body?.notUpdated?.singleton;
|
||||
if (bad) throw new Error(bad.description ?? bad.type);
|
||||
setPromSaved({ on: promOn, user: promUser.trim() });
|
||||
if (promPassword !== null) setPasswordSaved(true);
|
||||
setSavedProvider(provider);
|
||||
setOtelDirty(false);
|
||||
onSaved?.();
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: t('metricsConnect.failed', 'Not saved'),
|
||||
description: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const tryScrape = async () => {
|
||||
setCheck(t('metricsConnect.checking', 'Checking…'));
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
if (promUser && promPassword) headers.Authorization = `Basic ${btoa(`${promUser}:${promPassword}`)}`;
|
||||
const res = await fetch(scrapeUrl(base), { headers, cache: 'no-store' });
|
||||
if (res.status === 401) {
|
||||
setCheck(t('metricsConnect.needsLogin', 'It answers, and asks for the user name and password: as it should.'));
|
||||
} else if (res.ok) {
|
||||
const text = await res.text();
|
||||
const lines = text.split('\n').filter((l) => l && !l.startsWith('#')).length;
|
||||
setCheck(t('metricsConnect.works', 'It answers with {{count}} measurements.', { count: lines }));
|
||||
} else {
|
||||
setCheck(t('metricsConnect.status', 'It answered {{status}}.', { status: res.status }));
|
||||
}
|
||||
} catch {
|
||||
setCheck(
|
||||
t(
|
||||
'metricsConnect.cantCheck',
|
||||
'The browser couldn’t read it (usually cross-site rules). Check the target on your Prometheus instead.',
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const field = (key: keyof OtelInput, label: string, placeholder: string, hint?: string, secret = false) => (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`otel-${key}`}>{label}</Label>
|
||||
<Input
|
||||
id={`otel-${key}`}
|
||||
type={secret ? 'password' : 'text'}
|
||||
autoComplete={secret ? 'new-password' : 'off'}
|
||||
value={otel[key]}
|
||||
placeholder={placeholder}
|
||||
disabled={!canUpdate}
|
||||
onChange={(e) => {
|
||||
setOtel((o) => ({ ...o, [key]: e.target.value }));
|
||||
setOtelDirty(true);
|
||||
}}
|
||||
className="max-w-lg"
|
||||
/>
|
||||
{hint && <p className="text-xs text-muted-foreground">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl space-y-5 rounded-xl border bg-card p-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<Radio className="h-5 w-5 text-primary" />
|
||||
<p className="flex-1 font-medium">{t('metricsConnect.title', 'Where the server’s numbers go')}</p>
|
||||
{(promChanged || otelChanged) && (
|
||||
<Button type="button" size="sm" onClick={() => void save()} disabled={busy || !otelValid || !promValid}>
|
||||
{busy && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{t('metricsConnect.save', 'Save changes')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<section className="space-y-3">
|
||||
<label className="flex items-start justify-between gap-4">
|
||||
<span>
|
||||
<span className="font-medium">{t('metricsConnect.prom', 'Let Prometheus collect them')}</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{t('metricsConnect.promHint', 'Prometheus fetches them from this server every so often.')}
|
||||
</span>
|
||||
</span>
|
||||
<Switch checked={promOn} disabled={!canUpdate} onCheckedChange={setPromOn} />
|
||||
</label>
|
||||
{promOn && (
|
||||
<div className="space-y-3 border-l-2 pl-4">
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="prom-user">{t('metricsConnect.user', 'User name')}</Label>
|
||||
<Input
|
||||
id="prom-user"
|
||||
value={promUser}
|
||||
disabled={!canUpdate}
|
||||
onChange={(e) => setPromUser(e.target.value)}
|
||||
className="w-48"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={!canUpdate || !promUser.trim()}
|
||||
onClick={() => {
|
||||
setPromPassword(newPassword());
|
||||
setPasswordSaved(false);
|
||||
}}
|
||||
>
|
||||
{promPassword
|
||||
? t('metricsConnect.newPassword', 'Make another password')
|
||||
: promSaved.on && promSaved.user
|
||||
? t('metricsConnect.replacePassword', 'Replace the password')
|
||||
: t('metricsConnect.makePassword', 'Make a password')}
|
||||
</Button>
|
||||
</div>
|
||||
{!promUser.trim() && (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400">
|
||||
{t(
|
||||
'metricsConnect.open',
|
||||
'Without a user name anyone who can reach the server can read these numbers, which show how busy it is.',
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{promPassword && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
'metricsConnect.shownOnce',
|
||||
'The new password is in the scrape job below. It’s shown only now: copy it before you leave.',
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm">
|
||||
{t('metricsConnect.address', 'Address:')}{' '}
|
||||
<code className="rounded bg-muted px-1 text-xs">{scrapeUrl(base)}</code>
|
||||
</p>
|
||||
<CopyBlock text={scrapeConfig(base, promUser.trim(), promPassword)} />
|
||||
{!promChanged && promSaved.on && (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => void tryScrape()}>
|
||||
{t('metricsConnect.check', 'Check it answers')}
|
||||
</Button>
|
||||
{check && <span className="text-xs text-muted-foreground">{check}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="space-y-3 border-t pt-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('metricsConnect.otel', 'Send them to an OpenTelemetry service')}</Label>
|
||||
<Select
|
||||
value={provider}
|
||||
disabled={!canUpdate}
|
||||
onValueChange={(v) => {
|
||||
setProvider(v as OtelProvider);
|
||||
setOtelDirty(true);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="max-w-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="off">{t('metricsConnect.off', 'Don’t send them')}</SelectItem>
|
||||
<SelectItem value="honeycomb">Honeycomb</SelectItem>
|
||||
<SelectItem value="grafana">Grafana Cloud</SelectItem>
|
||||
<SelectItem value="collectorHttp">
|
||||
{t('metricsConnect.collectorHttp', 'Your own collector (HTTP)')}
|
||||
</SelectItem>
|
||||
<SelectItem value="collectorGrpc">
|
||||
{t('metricsConnect.collectorGrpc', 'Your own collector (gRPC)')}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{provider === 'honeycomb' && (
|
||||
<>
|
||||
{field(
|
||||
'key',
|
||||
t('metricsConnect.hcKey', 'API key'),
|
||||
savedProvider === 'honeycomb' ? t('metricsConnect.keep', 'Leave empty to keep the saved one') : '',
|
||||
t('metricsConnect.hcKeyHint', 'An ingest key from your Honeycomb environment’s API keys.'),
|
||||
true,
|
||||
)}
|
||||
{field('extra', t('metricsConnect.hcDataset', 'Dataset'), 'inbuxa')}
|
||||
</>
|
||||
)}
|
||||
{provider === 'grafana' && (
|
||||
<>
|
||||
{field(
|
||||
'endpoint',
|
||||
t('metricsConnect.gcEndpoint', 'OTLP endpoint'),
|
||||
'https://otlp-gateway-prod-eu-west-2.grafana.net/otlp',
|
||||
t('metricsConnect.gcEndpointHint', 'From your stack’s OpenTelemetry page in the Grafana Cloud portal.'),
|
||||
)}
|
||||
{field('extra', t('metricsConnect.gcInstance', 'Instance ID'), '123456')}
|
||||
{field(
|
||||
'key',
|
||||
t('metricsConnect.gcToken', 'Token'),
|
||||
'',
|
||||
t(
|
||||
'metricsConnect.gcTokenHint',
|
||||
'An access policy token with metrics:write. Needed again whenever these settings change.',
|
||||
),
|
||||
true,
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
'metricsConnect.delta',
|
||||
'This server sends counters as deltas. If some counters don’t show up, put an OpenTelemetry Collector with the deltatocumulative processor in between.',
|
||||
)}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{provider === 'collectorHttp' &&
|
||||
field(
|
||||
'endpoint',
|
||||
t('metricsConnect.collectorUrl', 'Collector address'),
|
||||
'http://collector.internal:4318',
|
||||
t('metricsConnect.collectorHttpHint', '/v1/metrics is added if it isn’t there.'),
|
||||
)}
|
||||
{provider === 'collectorGrpc' &&
|
||||
field('endpoint', t('metricsConnect.collectorUrl', 'Collector address'), 'http://collector.internal:4317')}
|
||||
{provider !== 'off' && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('metricsConnect.pushEvery', 'The server sends them once a minute.')}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { DynamicForm } from '@/components/forms/DynamicForm';
|
||||
import { MetricsConnect } from './MetricsConnect';
|
||||
|
||||
/** A metrics page: where the numbers go, then this page's own settings folded underneath. */
|
||||
export function MetricsPage({ viewName }: { viewName: string }) {
|
||||
const { t } = useTranslation();
|
||||
const [version, setVersion] = useState(0);
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<MetricsConnect onSaved={() => setVersion((v) => v + 1)} />
|
||||
<Collapsible className="mx-auto max-w-4xl">
|
||||
<CollapsibleTrigger className="group flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground">
|
||||
<ChevronDown className="h-4 w-4 transition-transform group-data-[state=closed]:-rotate-90" />
|
||||
{t('metricsConnect.more', 'Which metrics, and every setting on this page')}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="pt-4">
|
||||
<DynamicForm key={version} viewName={viewName} objectId="singleton" />
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { httpMetricsEndpoint, newPassword, otelValue, providerOf, scrapeConfig, scrapeUrl } from './connect';
|
||||
|
||||
describe('prometheus', () => {
|
||||
it('scrapes /metrics/prometheus on the server', () => {
|
||||
expect(scrapeUrl('https://mail.example.org/')).toBe('https://mail.example.org/metrics/prometheus');
|
||||
});
|
||||
|
||||
it('writes a scrape job, with basic auth when there is a user', () => {
|
||||
expect(scrapeConfig('https://mail.example.org', 'prometheus', 's3cret')).toBe(
|
||||
[
|
||||
'scrape_configs:',
|
||||
' - job_name: inbuxa',
|
||||
' scheme: https',
|
||||
' metrics_path: /metrics/prometheus',
|
||||
' static_configs:',
|
||||
" - targets: ['mail.example.org']",
|
||||
' basic_auth:',
|
||||
" username: 'prometheus'",
|
||||
" password: 's3cret'",
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
expect(scrapeConfig('http://localhost:8080', '', null)).not.toContain('basic_auth');
|
||||
expect(scrapeConfig('http://localhost:8080', '', null)).toContain("targets: ['localhost:8080']");
|
||||
});
|
||||
|
||||
it('makes long random passwords', () => {
|
||||
const a = newPassword();
|
||||
expect(a).toMatch(/^[A-Za-z0-9]{20,24}$/);
|
||||
expect(newPassword()).not.toBe(a);
|
||||
});
|
||||
});
|
||||
|
||||
describe('opentelemetry', () => {
|
||||
it('adds the signal path to a bare HTTP collector address', () => {
|
||||
expect(httpMetricsEndpoint('http://collector:4318')).toBe('http://collector:4318/v1/metrics');
|
||||
expect(httpMetricsEndpoint('http://collector:4318/v1/metrics/')).toBe('http://collector:4318/v1/metrics');
|
||||
});
|
||||
|
||||
it('builds each provider’s settings, and reads them back', () => {
|
||||
const hc = otelValue('honeycomb', { endpoint: '', key: 'abc', extra: '' });
|
||||
expect(hc).toMatchObject({
|
||||
'@type': 'Http',
|
||||
endpoint: 'https://api.honeycomb.io/v1/metrics',
|
||||
httpHeaders: { 'x-honeycomb-team': 'abc', 'x-honeycomb-dataset': 'inbuxa' },
|
||||
});
|
||||
const gc = otelValue('grafana', {
|
||||
endpoint: 'https://otlp-gateway-prod-eu-west-2.grafana.net/otlp',
|
||||
key: 'tok',
|
||||
extra: '123456',
|
||||
});
|
||||
expect(gc).toMatchObject({
|
||||
endpoint: 'https://otlp-gateway-prod-eu-west-2.grafana.net/otlp/v1/metrics',
|
||||
httpAuth: { '@type': 'Basic', username: '123456', secret: 'tok' },
|
||||
});
|
||||
expect(providerOf(hc)).toBe('honeycomb');
|
||||
expect(providerOf(gc)).toBe('grafana');
|
||||
expect(providerOf(otelValue('collectorGrpc', { endpoint: 'http://c:4317', key: '', extra: '' }))).toBe(
|
||||
'collectorGrpc',
|
||||
);
|
||||
expect(providerOf({ '@type': 'Disabled' })).toBe('off');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* inbuxa: sending the server's metrics somewhere (settings-reorg, second
|
||||
* wave). Two ways, both on x:Metrics:
|
||||
*
|
||||
* - Prometheus scrapes GET /metrics/prometheus on the server's own web
|
||||
* address, optionally behind Basic authentication (http/src/request.rs).
|
||||
* - The server pushes to an OpenTelemetry endpoint over HTTP or gRPC. An
|
||||
* HTTP endpoint set in the settings is used exactly as given
|
||||
* (opentelemetry-otlp resolve_http_endpoint), so it must end in
|
||||
* /v1/metrics; the guide adds that to a bare collector address. Counters
|
||||
* go out as deltas (Temporality::Delta).
|
||||
*/
|
||||
|
||||
export type OtelProvider = 'off' | 'honeycomb' | 'grafana' | 'collectorHttp' | 'collectorGrpc';
|
||||
|
||||
export interface OtelInput {
|
||||
endpoint: string;
|
||||
/** Honeycomb API key, or Grafana Cloud token. */
|
||||
key: string;
|
||||
/** Honeycomb dataset, or Grafana Cloud instance ID. */
|
||||
extra: string;
|
||||
}
|
||||
|
||||
export function scrapeUrl(base: string): string {
|
||||
return `${base.replace(/\/+$/, '')}/metrics/prometheus`;
|
||||
}
|
||||
|
||||
/** A Prometheus scrape job for this server, ready to paste into prometheus.yml. */
|
||||
export function scrapeConfig(base: string, username: string, password: string | null): string {
|
||||
const url = new URL(scrapeUrl(base));
|
||||
const lines = [
|
||||
'scrape_configs:',
|
||||
' - job_name: inbuxa',
|
||||
` scheme: ${url.protocol.replace(':', '')}`,
|
||||
` metrics_path: ${url.pathname}`,
|
||||
' static_configs:',
|
||||
` - targets: ['${url.host}']`,
|
||||
];
|
||||
if (username) {
|
||||
lines.push(
|
||||
' basic_auth:',
|
||||
` username: '${username}'`,
|
||||
` password: '${password ?? '<the password you set>'}'`,
|
||||
);
|
||||
}
|
||||
return lines.join('\n') + '\n';
|
||||
}
|
||||
|
||||
/** An OTLP/HTTP metrics URL: a bare collector address gets the signal path. */
|
||||
export function httpMetricsEndpoint(raw: string): string {
|
||||
const s = raw.trim().replace(/\/+$/, '');
|
||||
if (/\/v1\/metrics$/.test(s)) return s;
|
||||
return `${s}/v1/metrics`;
|
||||
}
|
||||
|
||||
/** What x:Metrics.openTelemetry becomes for a provider. */
|
||||
export function otelValue(provider: OtelProvider, input: OtelInput): Record<string, unknown> {
|
||||
switch (provider) {
|
||||
case 'off':
|
||||
return { '@type': 'Disabled' };
|
||||
case 'honeycomb':
|
||||
return {
|
||||
'@type': 'Http',
|
||||
endpoint: 'https://api.honeycomb.io/v1/metrics',
|
||||
httpAuth: { '@type': 'Unauthenticated' },
|
||||
httpHeaders: { 'x-honeycomb-team': input.key.trim(), 'x-honeycomb-dataset': input.extra.trim() || 'inbuxa' },
|
||||
};
|
||||
case 'grafana':
|
||||
return {
|
||||
'@type': 'Http',
|
||||
endpoint: httpMetricsEndpoint(input.endpoint.replace(/\/otlp\/?$/, '') + '/otlp'),
|
||||
httpAuth: { '@type': 'Basic', username: input.extra.trim(), secret: input.key },
|
||||
httpHeaders: {},
|
||||
};
|
||||
case 'collectorHttp':
|
||||
return {
|
||||
'@type': 'Http',
|
||||
endpoint: httpMetricsEndpoint(input.endpoint),
|
||||
httpAuth: { '@type': 'Unauthenticated' },
|
||||
httpHeaders: {},
|
||||
};
|
||||
case 'collectorGrpc':
|
||||
return { '@type': 'Grpc', endpoint: input.endpoint.trim() || null };
|
||||
}
|
||||
}
|
||||
|
||||
/** Which provider a saved value most likely is. */
|
||||
export function providerOf(value: Record<string, unknown> | null | undefined): OtelProvider {
|
||||
const type = value?.['@type'];
|
||||
if (type === 'Grpc') return 'collectorGrpc';
|
||||
if (type !== 'Http') return 'off';
|
||||
const endpoint = String(value?.endpoint ?? '');
|
||||
if (endpoint.includes('honeycomb.io')) return 'honeycomb';
|
||||
if (endpoint.includes('grafana.net')) return 'grafana';
|
||||
return 'collectorHttp';
|
||||
}
|
||||
|
||||
/** A password for the scrape endpoint, made in the browser and shown once. */
|
||||
export function newPassword(bytes = 18): string {
|
||||
const a = new Uint8Array(bytes);
|
||||
crypto.getRandomValues(a);
|
||||
return btoa(String.fromCharCode(...a))
|
||||
.replace(/[+/=]/g, '')
|
||||
.slice(0, 24);
|
||||
}
|
||||
Reference in new issue
Block a user