* init otel

* deflock group

* no internal metrics

* only log errors for now
This commit is contained in:
Will Freeman
2026-04-26 13:20:32 -06:00
committed by GitHub
parent c99d4cf597
commit a39f1a6a36
10 changed files with 533 additions and 103 deletions
+32 -15
View File
@@ -1,5 +1,7 @@
import { Type, Static } from '@sinclair/typebox';
import { type Span, SpanKind, SpanStatusCode, context, trace } from '@opentelemetry/api';
import { tracer } from '../telemetry';
const GITHUB_TOKEN = process.env.GITHUB_TOKEN || '';
const graphQLEndpoint = 'https://api.github.com/graphql';
@@ -18,22 +20,37 @@ export type Sponsor = Static<typeof SponsorSchema>;
export const SponsorsResponseSchema = Type.Array(SponsorSchema);
export class GithubClient {
async getSponsors(username: string): Promise<Sponsor[]> {
const query = `query { user(login: \"${username}\") { sponsorshipsAsMaintainer(first: 100) { nodes { sponsor { login name avatarUrl url } } } } }`;
async getSponsors(username: string, parentSpan?: Span): Promise<Sponsor[]> {
const query = `query { user(login: "${username}") { sponsorshipsAsMaintainer(first: 100) { nodes { sponsor { login name avatarUrl url } } } } }`;
const body = JSON.stringify({ query, variables: '' });
const response = await fetch(graphQLEndpoint, {
method: 'POST',
headers: {
'Authorization': `Bearer ${GITHUB_TOKEN}`,
'User-Agent': 'Shotgun',
'Content-Type': 'application/json',
},
body,
});
if (!response.ok) {
throw new Error(`Failed to get sponsors: ${response.status}`);
const ctx = parentSpan ? trace.setSpan(context.active(), parentSpan) : context.active();
const span = tracer.startSpan('github.getSponsors', {
kind: SpanKind.CLIENT,
attributes: { 'peer.service': 'github', 'http.request.method': 'POST' },
}, ctx);
try {
const response = await fetch(graphQLEndpoint, {
method: 'POST',
headers: {
'Authorization': `Bearer ${GITHUB_TOKEN}`,
'User-Agent': 'Shotgun',
'Content-Type': 'application/json',
},
body,
});
span.setAttribute('http.response.status_code', response.status);
if (!response.ok) {
throw new Error(`Failed to get sponsors: ${response.status}`);
}
const json = await response.json();
return json?.data?.user?.sponsorshipsAsMaintainer?.nodes || [];
} catch (err) {
span.recordException(err as Error);
span.setStatus({ code: SpanStatusCode.ERROR, message: (err as Error).message });
span.setAttribute('error.type', 'upstream_error');
throw err;
} finally {
span.end();
}
const json = await response.json();
return json?.data?.user?.sponsorshipsAsMaintainer?.nodes || [];
}
}
+40 -13
View File
@@ -1,6 +1,8 @@
import { createCache, Cache } from 'cache-manager';
import { Type, Static } from '@sinclair/typebox';
import { type Span, SpanKind, SpanStatusCode, context, trace } from '@opentelemetry/api';
import { tracer, otelLogger, SeverityNumber } from '../telemetry';
const { DiskStore } = require('cache-manager-fs-hash');
export const NominatimResultSchema = Type.Object({
@@ -53,7 +55,7 @@ const cache: Cache = createCache({
export class NominatimClient {
baseUrl = 'https://nominatim.openstreetmap.org/search';
async geocodePhrase(query: string, includeGeoJson: boolean = false): Promise<NominatimResult[]> {
async geocodePhrase(query: string, includeGeoJson: boolean = false, parentSpan?: Span): Promise<NominatimResult[]> {
const cacheKey = `geocode:${query}`;
const cached = await cache.get(cacheKey);
if (cached) {
@@ -61,21 +63,46 @@ export class NominatimClient {
}
const geojsonParam = includeGeoJson ? '&polygon_geojson=1' : '';
const url = `${this.baseUrl}?q=${encodeURIComponent(query)}&format=json&addressdetails=1&limit=8&countrycodes=us&dedupe=1${geojsonParam}`;
const response = await fetch(url, {
headers: {
'User-Agent': 'DeFlock/1.2',
},
});
if (!response.ok) {
throw new Error(`Failed to geocode phrase: ${response.status}`);
const ctx = parentSpan ? trace.setSpan(context.active(), parentSpan) : context.active();
const span = tracer.startSpan('nominatim.geocode', {
kind: SpanKind.CLIENT,
attributes: { 'peer.service': 'nominatim', 'http.request.method': 'GET' },
}, ctx);
try {
const response = await fetch(url, {
headers: { 'User-Agent': 'DeFlock/1.2' },
});
span.setAttribute('http.response.status_code', response.status);
if (!response.ok) {
const body = await response.text();
otelLogger.emit({
severityNumber: SeverityNumber.ERROR,
severityText: 'ERROR',
body: `Nominatim error: ${response.status}`,
attributes: {
'nominatim.status_code': response.status,
'nominatim.response_body': body,
'http.url': url,
},
context: trace.setSpan(context.active(), span),
});
throw new Error(`Failed to geocode phrase: ${response.status}`);
}
const json = await response.json();
await cache.set(cacheKey, json);
return json;
} catch (err) {
span.recordException(err as Error);
span.setStatus({ code: SpanStatusCode.ERROR, message: (err as Error).message });
span.setAttribute('error.type', 'upstream_error');
throw err;
} finally {
span.end();
}
const json = await response.json();
await cache.set(cacheKey, json);
return json;
}
async geocodeSingleResult(query: string): Promise<NominatimResult | null> {
const results = await this.geocodePhrase(query, true);
async geocodeSingleResult(query: string, parentSpan?: Span): Promise<NominatimResult | null> {
const results = await this.geocodePhrase(query, true, parentSpan);
if (!results.length) return null;
+35 -13
View File
@@ -1,25 +1,47 @@
import { type Span, SpanKind, SpanStatusCode, context, trace } from '@opentelemetry/api';
import { tracer } from '../telemetry';
const TURNSTILE_SECRET_KEY = process.env.TURNSTILE_SECRET_KEY || '';
const SITEVERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
export class TurnstileClient {
async verify(token: string, remoteIp?: string): Promise<boolean> {
async verify(token: string, remoteIp?: string, parentSpan?: Span): Promise<boolean> {
const body = new URLSearchParams({
secret: TURNSTILE_SECRET_KEY,
response: token,
...(remoteIp ? { remoteip: remoteIp } : {}),
});
const response = await fetch(SITEVERIFY_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
if (!response.ok) {
throw new Error(`Turnstile siteverify request failed: ${response.status}`);
const ctx = parentSpan ? trace.setSpan(context.active(), parentSpan) : context.active();
const span = tracer.startSpan('turnstile.verify', {
kind: SpanKind.CLIENT,
attributes: { 'peer.service': 'turnstile', 'http.request.method': 'POST' },
}, ctx);
try {
const response = await fetch(SITEVERIFY_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
span.setAttribute('http.response.status_code', response.status);
if (!response.ok) {
throw new Error(`Turnstile siteverify request failed: ${response.status}`);
}
const json = await response.json() as { success: boolean };
const success = json.success === true;
if (!success) {
span.setAttribute('error.type', 'captcha_failure');
span.setStatus({ code: SpanStatusCode.ERROR, message: 'Captcha verification failed' });
}
return success;
} catch (err) {
span.recordException(err as Error);
span.setStatus({ code: SpanStatusCode.ERROR, message: (err as Error).message });
if (!(err instanceof Error && err.message.startsWith('Turnstile'))) {
span.setAttribute('error.type', 'upstream_error');
}
throw err;
} finally {
span.end();
}
const json = await response.json() as { success: boolean };
return json.success === true;
}
}
+93 -53
View File
@@ -1,4 +1,6 @@
import { Type, Static } from '@sinclair/typebox';
import { type Span, SpanKind, SpanStatusCode, context, trace } from '@opentelemetry/api';
import { tracer } from '../telemetry';
const ZAMMAD_URL = process.env.ZAMMAD_URL || '';
const ZAMMAD_TOKEN = process.env.ZAMMAD_TOKEN || '';
@@ -44,76 +46,114 @@ export interface CreateTicketPayload {
}
export class ZammadClient {
private async upsertCustomer(name: string, email: string): Promise<number> {
private async upsertCustomer(name: string, email: string, parentSpan: Span): Promise<number> {
const normalizedEmail = email.toLowerCase();
const ctx = trace.setSpan(context.active(), parentSpan);
// Search for existing user by email
const searchResponse = await fetch(
`${ZAMMAD_URL}/api/v1/users/search?query=${encodeURIComponent(normalizedEmail)}&limit=1`,
{
headers: { 'Authorization': `Token token=${ZAMMAD_TOKEN}` },
const searchSpan = tracer.startSpan('zammad.upsertCustomer.search', {
kind: SpanKind.CLIENT,
attributes: { 'peer.service': 'zammad', 'http.request.method': 'GET' },
}, ctx);
try {
const searchResponse = await fetch(
`${ZAMMAD_URL}/api/v1/users/search?query=${encodeURIComponent(normalizedEmail)}&limit=1`,
{
headers: { 'Authorization': `Token token=${ZAMMAD_TOKEN}` },
}
);
searchSpan.setAttribute('http.response.status_code', searchResponse.status);
if (searchResponse.ok) {
const users = await searchResponse.json() as Array<{ id: number; email: string }>;
const match = users.find(u => u.email?.toLowerCase() === normalizedEmail);
if (match) return match.id;
}
);
if (searchResponse.ok) {
const users = await searchResponse.json() as Array<{ id: number; email: string }>;
const match = users.find(u => u.email?.toLowerCase() === normalizedEmail);
if (match) return match.id;
} catch (err) {
searchSpan.recordException(err as Error);
searchSpan.setStatus({ code: SpanStatusCode.ERROR, message: (err as Error).message });
throw err;
} finally {
searchSpan.end();
}
// Create the customer if not found
const createResponse = await fetch(`${ZAMMAD_URL}/api/v1/users`, {
method: 'POST',
headers: {
'Authorization': `Token token=${ZAMMAD_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ firstname: name, email: normalizedEmail, roles: ['Customer'] }),
});
if (!createResponse.ok) {
const text = await createResponse.text();
throw new Error(`Zammad customer creation failed: ${createResponse.status} ${text}`);
const createSpan = tracer.startSpan('zammad.upsertCustomer.create', {
kind: SpanKind.CLIENT,
attributes: { 'peer.service': 'zammad', 'http.request.method': 'POST' },
}, ctx);
try {
const createResponse = await fetch(`${ZAMMAD_URL}/api/v1/users`, {
method: 'POST',
headers: {
'Authorization': `Token token=${ZAMMAD_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ firstname: name, email: normalizedEmail, roles: ['Customer'] }),
});
createSpan.setAttribute('http.response.status_code', createResponse.status);
if (!createResponse.ok) {
const text = await createResponse.text();
throw new Error(`Zammad customer creation failed: ${createResponse.status} ${text}`);
}
const user = await createResponse.json() as { id: number };
return user.id;
} catch (err) {
createSpan.recordException(err as Error);
createSpan.setStatus({ code: SpanStatusCode.ERROR, message: (err as Error).message });
throw err;
} finally {
createSpan.end();
}
const user = await createResponse.json() as { id: number };
return user.id;
}
async createTicket(payload: CreateTicketPayload): Promise<void> {
const { name, email, topic, subject, message } = payload;
const group = TOPIC_GROUP_MAP[topic];
const customerId = await this.upsertCustomer(name, email);
const body = JSON.stringify({
title: subject,
group,
priority: topic === 'media' ? '3 high' : '2 normal',
customer_id: customerId,
article: {
subject,
body: message,
type: 'email',
sender: 'Customer',
from: `${name} <${email}>`,
to: 'contact@deflock.org',
internal: false,
},
const span = tracer.startSpan('zammad.createTicket', {
kind: SpanKind.CLIENT,
attributes: { 'peer.service': 'zammad', 'http.request.method': 'POST' },
});
try {
const customerId = await this.upsertCustomer(name, email, span);
const response = await fetch(`${ZAMMAD_URL}/api/v1/tickets`, {
method: 'POST',
headers: {
'Authorization': `Token token=${ZAMMAD_TOKEN}`,
'Content-Type': 'application/json',
},
body,
});
const body = JSON.stringify({
title: subject,
group,
priority: topic === 'media' ? '3 high' : '2 normal',
customer_id: customerId,
article: {
subject,
body: message,
type: 'email',
sender: 'Customer',
from: `${name} <${email}>`,
to: 'contact@deflock.org',
internal: false,
},
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Zammad ticket creation failed: ${response.status} ${text}`);
const ctx = trace.setSpan(context.active(), span);
const response = await context.with(ctx, () => fetch(`${ZAMMAD_URL}/api/v1/tickets`, {
method: 'POST',
headers: {
'Authorization': `Token token=${ZAMMAD_TOKEN}`,
'Content-Type': 'application/json',
},
body,
}));
span.setAttribute('http.response.status_code', response.status);
if (!response.ok) {
const text = await response.text();
throw new Error(`Zammad ticket creation failed: ${response.status} ${text}`);
}
} catch (err) {
span.recordException(err as Error);
span.setStatus({ code: SpanStatusCode.ERROR, message: (err as Error).message });
span.setAttribute('error.type', 'upstream_error');
throw err;
} finally {
span.end();
}
}
}