mirror of
https://github.com/FoggedLens/deflock.git
synced 2026-05-15 21:08:05 +02:00
remove spans, log geocoding shit bc of cost
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Classifies a geocoding query string into a common geographic query type.
|
||||
*/
|
||||
|
||||
export type GeoQueryType =
|
||||
| 'zip_code' // 5-digit US ZIP, e.g. "90210"
|
||||
| 'coordinates' // lat/lon pair, e.g. "34.0522, -118.2437"
|
||||
| 'address' // street address, e.g. "123 Main St, Springfield, IL"
|
||||
| 'city_state' // city + 2-letter state, e.g. "Portland, OR"
|
||||
| 'city' // bare city name, e.g. "Portland"
|
||||
| 'state' // US state name or abbreviation, e.g. "Oregon" or "OR"
|
||||
| 'other';
|
||||
|
||||
const ZIP_RE = /^\d{5}$/;
|
||||
const COORDS_RE = /^-?\d{1,3}(\.\d+)?\s*,\s*-?\d{1,3}(\.\d+)?$/;
|
||||
// Street address: starts with a number followed by a street name fragment
|
||||
const ADDRESS_RE = /^\d+\s+\w/;
|
||||
// "City, ST" — word(s), comma, exactly 2 uppercase letters
|
||||
const CITY_STATE_RE = /^[\w\s.''-]+,\s*[A-Z]{2}$/;
|
||||
|
||||
const US_STATES = new Set([
|
||||
'alabama','alaska','arizona','arkansas','california','colorado','connecticut',
|
||||
'delaware','florida','georgia','hawaii','idaho','illinois','indiana','iowa',
|
||||
'kansas','kentucky','louisiana','maine','maryland','massachusetts','michigan',
|
||||
'minnesota','mississippi','missouri','montana','nebraska','nevada',
|
||||
'new hampshire','new jersey','new mexico','new york','north carolina',
|
||||
'north dakota','ohio','oklahoma','oregon','pennsylvania','rhode island',
|
||||
'south carolina','south dakota','tennessee','texas','utah','vermont',
|
||||
'virginia','washington','west virginia','wisconsin','wyoming',
|
||||
'district of columbia',
|
||||
// Abbreviations
|
||||
'al','ak','az','ar','ca','co','ct','de','fl','ga','hi','id','il','in','ia',
|
||||
'ks','ky','la','me','md','ma','mi','mn','ms','mo','mt','ne','nv','nh','nj',
|
||||
'nm','ny','nc','nd','oh','ok','or','pa','ri','sc','sd','tn','tx','ut','vt',
|
||||
'va','wa','wv','wi','wy','dc',
|
||||
]);
|
||||
|
||||
export function classifyGeoQuery(query: string): GeoQueryType {
|
||||
const q = query.trim();
|
||||
|
||||
if (ZIP_RE.test(q)) return 'zip_code';
|
||||
if (COORDS_RE.test(q)) return 'coordinates';
|
||||
if (ADDRESS_RE.test(q)) return 'address';
|
||||
if (CITY_STATE_RE.test(q)) return 'city_state';
|
||||
if (US_STATES.has(q.toLowerCase())) return 'state';
|
||||
// Single word or short multi-word without punctuation — treat as city
|
||||
if (/^[\w\s.'']+$/.test(q) && q.length >= 2) return 'city';
|
||||
|
||||
return 'other';
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
|
||||
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';
|
||||
@@ -20,37 +18,22 @@ export type Sponsor = Static<typeof SponsorSchema>;
|
||||
export const SponsorsResponseSchema = Type.Array(SponsorSchema);
|
||||
|
||||
export class GithubClient {
|
||||
async getSponsors(username: string, parentSpan?: Span): Promise<Sponsor[]> {
|
||||
async getSponsors(username: string): Promise<Sponsor[]> {
|
||||
const query = `query { user(login: "${username}") { sponsorshipsAsMaintainer(first: 100) { nodes { sponsor { login name avatarUrl url } } } } }`;
|
||||
const body = JSON.stringify({ query, variables: '' });
|
||||
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 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 json = await response.json();
|
||||
return json?.data?.user?.sponsorshipsAsMaintainer?.nodes || [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
|
||||
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';
|
||||
import { isZipCode, lookupZipCode } from './ZipCodeService';
|
||||
import { otelLogger, SeverityNumber } from '../telemetry';
|
||||
import { classifyGeoQuery } from './GeoQueryClassifier';
|
||||
import { lookupZipCode } from './ZipCodeService';
|
||||
const { DiskStore } = require('cache-manager-fs-hash');
|
||||
|
||||
export const NominatimResultSchema = Type.Object({
|
||||
@@ -56,9 +56,9 @@ const cache: Cache = createCache({
|
||||
export class NominatimClient {
|
||||
baseUrl = 'https://nominatim.openstreetmap.org/search';
|
||||
|
||||
async geocodePhrase(query: string, includeGeoJson: boolean = false, parentSpan?: Span): Promise<NominatimResult[]> {
|
||||
async geocodePhrase(query: string, includeGeoJson: boolean = false): Promise<NominatimResult[]> {
|
||||
// Short-circuit for ZIP codes — serve from local data, no Nominatim call needed
|
||||
if (isZipCode(query)) {
|
||||
if (classifyGeoQuery(query) === 'zip_code') {
|
||||
const zipResult = lookupZipCode(query.trim());
|
||||
return zipResult ? [zipResult] : [];
|
||||
}
|
||||
@@ -70,16 +70,10 @@ 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 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({
|
||||
@@ -91,7 +85,6 @@ export class NominatimClient {
|
||||
'nominatim.response_body': body,
|
||||
'http.url': url,
|
||||
},
|
||||
context: trace.setSpan(context.active(), span),
|
||||
});
|
||||
throw new Error(`Failed to geocode phrase: ${response.status}`);
|
||||
}
|
||||
@@ -99,17 +92,12 @@ export class NominatimClient {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
async geocodeSingleResult(query: string, parentSpan?: Span): Promise<NominatimResult | null> {
|
||||
const results = await this.geocodePhrase(query, true, parentSpan);
|
||||
async geocodeSingleResult(query: string): Promise<NominatimResult | null> {
|
||||
const results = await this.geocodePhrase(query, true);
|
||||
|
||||
if (!results.length) return null;
|
||||
|
||||
|
||||
@@ -1,47 +1,22 @@
|
||||
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, parentSpan?: Span): Promise<boolean> {
|
||||
async verify(token: string, remoteIp?: string): Promise<boolean> {
|
||||
const body = new URLSearchParams({
|
||||
secret: TURNSTILE_SECRET_KEY,
|
||||
response: token,
|
||||
...(remoteIp ? { remoteip: remoteIp } : {}),
|
||||
});
|
||||
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 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 json = await response.json() as { success: boolean };
|
||||
return json.success;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
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 || '';
|
||||
@@ -46,115 +44,72 @@ export interface CreateTicketPayload {
|
||||
}
|
||||
|
||||
export class ZammadClient {
|
||||
private async upsertCustomer(name: string, email: string, parentSpan: Span): Promise<number> {
|
||||
private async upsertCustomer(name: string, email: string): Promise<number> {
|
||||
const normalizedEmail = email.toLowerCase();
|
||||
const ctx = trace.setSpan(context.active(), parentSpan);
|
||||
|
||||
// Search for existing user by email
|
||||
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;
|
||||
const searchResponse = await fetch(
|
||||
`${ZAMMAD_URL}/api/v1/users/search?query=${encodeURIComponent(normalizedEmail)}&limit=1`,
|
||||
{
|
||||
headers: { 'Authorization': `Token token=${ZAMMAD_TOKEN}` },
|
||||
}
|
||||
} catch (err) {
|
||||
searchSpan.recordException(err as Error);
|
||||
searchSpan.setStatus({ code: SpanStatusCode.ERROR, message: (err as Error).message });
|
||||
throw err;
|
||||
} finally {
|
||||
searchSpan.end();
|
||||
);
|
||||
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;
|
||||
}
|
||||
|
||||
// Create the customer if not found
|
||||
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 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 user = await createResponse.json() as { id: number };
|
||||
return user.id;
|
||||
}
|
||||
|
||||
async createTicket(payload: CreateTicketPayload, parentSpan?: Span): Promise<void> {
|
||||
async createTicket(payload: CreateTicketPayload): Promise<void> {
|
||||
const { name, email, topic, subject, message } = payload;
|
||||
const group = TOPIC_GROUP_MAP[topic];
|
||||
|
||||
const ctx = parentSpan ? trace.setSpan(context.active(), parentSpan) : context.active();
|
||||
const span = tracer.startSpan('zammad.createTicket', {
|
||||
kind: SpanKind.CLIENT,
|
||||
attributes: { 'peer.service': 'zammad', 'http.request.method': 'POST' },
|
||||
}, ctx);
|
||||
try {
|
||||
const customerId = await this.upsertCustomer(name, email, span);
|
||||
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 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 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();
|
||||
const response = await fetch(`${ZAMMAD_URL}/api/v1/tickets`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Token token=${ZAMMAD_TOKEN}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Zammad ticket creation failed: ${response.status} ${text}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user