Add Globalping API for Korea-based latency measurement in domain diagnostics

Integrates Globalping API to measure ping and HTTP response times from
Korean probes (Seoul, Chuncheon, Daejeon). Results include per-probe
ping stats and HTTP timing breakdown (DNS, TCP, TLS, TTFB).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
kappa
2026-02-12 03:33:23 +09:00
parent d5600a5d25
commit 0d24350445

View File

@@ -9,7 +9,7 @@
*/ */
import { connect } from 'cloudflare:sockets'; import { connect } from 'cloudflare:sockets';
import type { NetworkDiagnosticReport, DnsResolverResult, IspDnsBlockResult, HttpCheckResult, TcpCheckResult } from '../types'; import type { NetworkDiagnosticReport, DnsResolverResult, IspDnsBlockResult, HttpCheckResult, TcpCheckResult, GlobalpingProbeResult } from '../types';
import { createLogger } from '../utils/logger'; import { createLogger } from '../utils/logger';
const logger = createLogger('network-diagnostic'); const logger = createLogger('network-diagnostic');
@@ -410,6 +410,170 @@ async function checkTcpPorts(domain: string, ports?: number[]): Promise<TcpCheck
); );
} }
// ============================================================================
// Globalping — Korea Probe Measurements (Ping + HTTP)
// ============================================================================
const GLOBALPING_API = 'https://api.globalping.io/v1/measurements';
const GLOBALPING_POLL_INTERVAL_MS = 2000;
const GLOBALPING_MAX_WAIT_MS = 15000;
interface GlobalpingMeasurementResponse {
id: string;
status: string;
results?: GlobalpingRawResult[];
}
interface GlobalpingRawResult {
probe: {
city: string;
network: string;
asn: number;
};
result: {
status: string;
stats?: {
min: number;
avg: number;
max: number;
loss: number;
};
statusCode?: number;
timings?: {
total: number;
dns: number;
tcp: number;
tls: number;
firstByte: number;
};
rawOutput?: string;
};
}
async function createGlobalpingMeasurement(
type: 'ping' | 'http',
target: string
): Promise<string | null> {
try {
const body: Record<string, unknown> = {
type,
target,
locations: [{ country: 'KR' }],
limit: 5,
};
if (type === 'ping') {
body.measurementOptions = { packets: 3 };
} else {
body.measurementOptions = {
protocol: 'HTTPS',
request: { method: 'HEAD' },
};
}
const response = await fetch(GLOBALPING_API, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!response.ok) {
logger.warn('Globalping measurement creation failed', {
type,
status: response.status,
});
return null;
}
const data = (await response.json()) as { id: string };
return data.id;
} catch (error) {
logger.error('Globalping API error', error as Error);
return null;
}
}
async function pollGlobalpingResult(id: string): Promise<GlobalpingRawResult[]> {
const deadline = Date.now() + GLOBALPING_MAX_WAIT_MS;
while (Date.now() < deadline) {
try {
const response = await fetch(`${GLOBALPING_API}/${id}`);
if (!response.ok) return [];
const data = (await response.json()) as GlobalpingMeasurementResponse;
if (data.status === 'finished') {
return data.results || [];
}
// Wait before polling again
await new Promise((r) => setTimeout(r, GLOBALPING_POLL_INTERVAL_MS));
} catch {
return [];
}
}
return [];
}
async function checkKoreaProbes(domain: string): Promise<GlobalpingProbeResult[]> {
// Create ping and HTTP measurements in parallel
const [pingId, httpId] = await Promise.all([
createGlobalpingMeasurement('ping', domain),
createGlobalpingMeasurement('http', domain),
]);
if (!pingId && !httpId) return [];
// Poll both results in parallel
const [pingResults, httpResults] = await Promise.all([
pingId ? pollGlobalpingResult(pingId) : Promise.resolve([]),
httpId ? pollGlobalpingResult(httpId) : Promise.resolve([]),
]);
// Merge ping and HTTP results by probe city+network
const probeMap = new Map<string, GlobalpingProbeResult>();
for (const r of pingResults) {
const key = `${r.probe.city}:${r.probe.network}`;
probeMap.set(key, {
city: r.probe.city,
network: r.probe.network,
asn: r.probe.asn,
ping: r.result.stats ? {
min: r.result.stats.min,
avg: r.result.stats.avg,
max: r.result.stats.max,
loss: r.result.stats.loss,
} : undefined,
error: r.result.status !== 'finished' ? r.result.rawOutput : undefined,
});
}
for (const r of httpResults) {
const key = `${r.probe.city}:${r.probe.network}`;
const existing = probeMap.get(key) || {
city: r.probe.city,
network: r.probe.network,
asn: r.probe.asn,
};
existing.http = r.result.timings ? {
statusCode: r.result.statusCode || 0,
totalMs: r.result.timings.total,
dnsMs: r.result.timings.dns,
tcpMs: r.result.timings.tcp,
tlsMs: r.result.timings.tls,
firstByteMs: r.result.timings.firstByte,
} : undefined;
if (!existing.error && r.result.status !== 'finished') {
existing.error = r.result.rawOutput;
}
probeMap.set(key, existing);
}
return [...probeMap.values()];
}
// ============================================================================ // ============================================================================
// Summary Generation // Summary Generation
// ============================================================================ // ============================================================================
@@ -418,7 +582,8 @@ function generateSummary(
dns: DnsResolverResult[], dns: DnsResolverResult[],
ispBlocking: IspDnsBlockResult[], ispBlocking: IspDnsBlockResult[],
http: HttpCheckResult[], http: HttpCheckResult[],
tcp: TcpCheckResult[] tcp: TcpCheckResult[],
koreaProbes: GlobalpingProbeResult[]
): string[] { ): string[] {
const summary: string[] = []; const summary: string[] = [];
@@ -479,6 +644,30 @@ function generateSummary(
summary.push(`TCP 연결 성공: ${tcpOk.map((r) => `포트 ${r.port} (${r.responseTimeMs}ms)`).join(', ')}`); summary.push(`TCP 연결 성공: ${tcpOk.map((r) => `포트 ${r.port} (${r.responseTimeMs}ms)`).join(', ')}`);
} }
// Korea probe results
if (koreaProbes.length > 0) {
const pingProbes = koreaProbes.filter((p) => p.ping);
if (pingProbes.length > 0) {
const avgPing = pingProbes.reduce((sum, p) => sum + (p.ping?.avg || 0), 0) / pingProbes.length;
const details = pingProbes.map((p) => `${p.city}/${p.network}: ${p.ping!.avg.toFixed(1)}ms`).join(', ');
summary.push(`한국 Ping: 평균 ${avgPing.toFixed(1)}ms (${details})`);
if (pingProbes.some((p) => (p.ping?.loss || 0) > 0)) {
const lossy = pingProbes.filter((p) => (p.ping?.loss || 0) > 0);
summary.push(`패킷 손실 감지: ${lossy.map((p) => `${p.city}: ${p.ping!.loss}%`).join(', ')}`);
}
}
const httpProbes = koreaProbes.filter((p) => p.http);
if (httpProbes.length > 0) {
const avgTotal = httpProbes.reduce((sum, p) => sum + (p.http?.totalMs || 0), 0) / httpProbes.length;
const details = httpProbes.map((p) => `${p.city}: ${p.http!.totalMs}ms(TTFB ${p.http!.firstByteMs}ms)`).join(', ');
summary.push(`한국 HTTP 응답: 평균 ${avgTotal.toFixed(0)}ms (${details})`);
}
const errorProbes = koreaProbes.filter((p) => p.error);
if (errorProbes.length > 0) {
summary.push(`한국 프로브 오류: ${errorProbes.map((p) => `${p.city}: ${p.error}`).join(', ')}`);
}
}
return summary; return summary;
} }
@@ -491,6 +680,7 @@ export interface NetworkDiagnosticOptions {
skipIspCheck?: boolean; skipIspCheck?: boolean;
skipHttpCheck?: boolean; skipHttpCheck?: boolean;
skipTcpCheck?: boolean; skipTcpCheck?: boolean;
skipKoreaProbes?: boolean;
} }
export async function runNetworkDiagnostic( export async function runNetworkDiagnostic(
@@ -501,19 +691,21 @@ export async function runNetworkDiagnostic(
const startTime = Date.now(); const startTime = Date.now();
// Run all checks in parallel // Run all checks in parallel
const [dns, ispBlocking, http, tcp] = await Promise.all([ const [dns, ispBlocking, http, tcp, koreaProbes] = await Promise.all([
resolveDoh(domain), resolveDoh(domain),
options?.skipIspCheck ? Promise.resolve([]) : checkIspBlocking(domain), options?.skipIspCheck ? Promise.resolve([]) : checkIspBlocking(domain),
options?.skipHttpCheck ? Promise.resolve([]) : checkHttp(domain), options?.skipHttpCheck ? Promise.resolve([]) : checkHttp(domain),
options?.skipTcpCheck ? Promise.resolve([]) : checkTcpPorts(domain, options?.ports), options?.skipTcpCheck ? Promise.resolve([]) : checkTcpPorts(domain, options?.ports),
options?.skipKoreaProbes ? Promise.resolve([]) : checkKoreaProbes(domain),
]); ]);
const summary = generateSummary(dns, ispBlocking, http, tcp); const summary = generateSummary(dns, ispBlocking, http, tcp, koreaProbes);
logger.info('네트워크 진단 완료', { logger.info('네트워크 진단 완료', {
domain, domain,
durationMs: Date.now() - startTime, durationMs: Date.now() - startTime,
summaryCount: summary.length, summaryCount: summary.length,
koreaProbeCount: koreaProbes.length,
}); });
return { return {
@@ -523,6 +715,7 @@ export async function runNetworkDiagnostic(
ispBlocking, ispBlocking,
http, http,
tcp, tcp,
koreaProbes,
summary, summary,
}; };
} }