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:
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user