11import { getLogger } from "@logtape/logtape" ;
22import { listExternalServices } from "@openstatus/services/external-service" ;
33import type { ExternalServiceRow } from "@openstatus/services/external-service" ;
4+ import {
5+ type UpsertExternalComponentInput ,
6+ upsertExternalComponentsForService ,
7+ } from "@openstatus/services/external-service-component" ;
48import {
59 type UpsertExternalIncidentInput ,
610 upsertExternalIncidentsForService ,
711} from "@openstatus/services/external-service-incident" ;
812import { FetchError , fetchers } from "@openstatus/status-fetcher" ;
913import type {
14+ NormalizedComponent ,
1015 NormalizedIncident ,
1116 StatusFetcher ,
1217 StatusPageEntry ,
@@ -24,7 +29,7 @@ const logger = getLogger(["workflow", "external-status"]);
2429
2530const tb = new OSTinybird ( env ( ) . TINY_BIRD_API_KEY ) ;
2631
27- // 10 per phase × 2 phases = peak 20 concurrent HTTP requests upstream; keeps
32+ // 10 per phase × 3 phases = peak 30 concurrent HTTP requests upstream; keeps
2833// Atlassian/Incident.io CDNs comfortable while still parallelising heavily.
2934const PHASE_CONCURRENCY = 10 ;
3035
@@ -84,6 +89,28 @@ function toUpsertInput(
8489 } ;
8590}
8691
92+ type ComponentSnapshot = {
93+ component_id : string ;
94+ external_service_id : number ;
95+ indicator : string ;
96+ status : string ;
97+ fetched_at : number ;
98+ } ;
99+
100+ function toComponentUpsertInput (
101+ component : NormalizedComponent ,
102+ ) : UpsertExternalComponentInput {
103+ return {
104+ upstreamComponentId : component . upstreamComponentId ,
105+ name : component . name ,
106+ description : component . description ,
107+ groupName : component . groupName ,
108+ position : component . position ,
109+ indicator : component . severity ,
110+ status : component . status ,
111+ } ;
112+ }
113+
87114type PhaseCounts = {
88115 successCount : number ;
89116 failureCount : number ;
@@ -101,6 +128,11 @@ type IncidentPhaseOutcome =
101128 | { kind : "skip" ; slug : string }
102129 | { kind : "fail" ; slug : string ; reason : string } ;
103130
131+ type ComponentPhaseOutcome =
132+ | { kind : "ok" ; slug : string ; snapshots : ComponentSnapshot [ ] }
133+ | { kind : "skip" ; slug : string }
134+ | { kind : "fail" ; slug : string ; reason : string } ;
135+
104136type Triplet = {
105137 row : ExternalServiceRow ;
106138 entry : StatusPageEntry ;
@@ -193,6 +225,73 @@ function runIncidentPhase(
193225 ) ;
194226}
195227
228+ function runComponentPhase (
229+ triplets : Triplet [ ] ,
230+ tickStartedAt : Date ,
231+ fetchedAt : number ,
232+ ) : Effect . Effect < ComponentPhaseOutcome [ ] > {
233+ return Effect . forEach (
234+ triplets ,
235+ ( { row, entry, fetcher } ) => {
236+ if ( ! fetcher || ! fetcher . fetchComponents ) {
237+ return Effect . succeed < ComponentPhaseOutcome > ( {
238+ kind : "skip" ,
239+ slug : entry . id ,
240+ } ) ;
241+ }
242+ return fetcher . fetchComponents ( entry ) . pipe (
243+ Effect . flatMap ( ( components ) =>
244+ Effect . tryPromise ( {
245+ try : ( ) =>
246+ upsertExternalComponentsForService ( {
247+ ctx : { db } ,
248+ externalServiceId : row . id ,
249+ components : components . map ( toComponentUpsertInput ) ,
250+ now : tickStartedAt ,
251+ } ) ,
252+ catch : ( e ) =>
253+ new FetchError ( {
254+ url : entry . status_page_url ,
255+ fetcherName : fetcher . name ,
256+ entryId : entry . id ,
257+ cause : e instanceof Error ? e : new Error ( String ( e ) ) ,
258+ } ) ,
259+ } ) . pipe (
260+ Effect . map ( ( result ) : ComponentPhaseOutcome => {
261+ // History rows key on our PK, so the upstream→PK map from the
262+ // upsert turns each normalized component into a snapshot.
263+ const byUpstream = new Map (
264+ components . map ( ( c ) => [ c . upstreamComponentId , c ] ) ,
265+ ) ;
266+ const snapshots : ComponentSnapshot [ ] = [ ] ;
267+ for ( const upserted of result . upserted ) {
268+ const c = byUpstream . get ( upserted . upstreamComponentId ) ;
269+ if ( ! c ) continue ;
270+ snapshots . push ( {
271+ component_id : String ( upserted . id ) ,
272+ external_service_id : row . id ,
273+ indicator : c . severity ,
274+ status : c . status ,
275+ fetched_at : fetchedAt ,
276+ } ) ;
277+ }
278+ return { kind : "ok" , slug : entry . id , snapshots } ;
279+ } ) ,
280+ ) ,
281+ ) ,
282+ Effect . catchAll ( ( err : FetchError ) =>
283+ Effect . succeed < ComponentPhaseOutcome > ( {
284+ kind : "fail" ,
285+ slug : entry . id ,
286+ reason : err . message ,
287+ } ) ,
288+ ) ,
289+ ) ;
290+ } ,
291+ { concurrency : PHASE_CONCURRENCY } ,
292+ ) ;
293+ }
294+
196295function summarizeStatus ( outcomes : StatusPhaseOutcome [ ] ) : {
197296 counts : PhaseCounts ;
198297 snapshots : Snapshot [ ] ;
@@ -254,6 +353,39 @@ function summarizeIncidents(outcomes: IncidentPhaseOutcome[]): PhaseCounts {
254353 } ;
255354}
256355
356+ function summarizeComponents ( outcomes : ComponentPhaseOutcome [ ] ) : {
357+ counts : PhaseCounts ;
358+ snapshots : ComponentSnapshot [ ] ;
359+ } {
360+ const snapshots : ComponentSnapshot [ ] = [ ] ;
361+ let successCount = 0 ;
362+ let failureCount = 0 ;
363+ let skippedCount = 0 ;
364+ for ( const o of outcomes ) {
365+ if ( o . kind === "ok" ) {
366+ successCount ++ ;
367+ snapshots . push ( ...o . snapshots ) ;
368+ } else if ( o . kind === "skip" ) {
369+ skippedCount ++ ;
370+ } else {
371+ failureCount ++ ;
372+ logger . warn (
373+ "external-status components: failed for slug={slug}: {reason}" ,
374+ { slug : o . slug , reason : o . reason } ,
375+ ) ;
376+ }
377+ }
378+ return {
379+ counts : {
380+ successCount,
381+ failureCount,
382+ skippedCount,
383+ total : outcomes . length ,
384+ } ,
385+ snapshots,
386+ } ;
387+ }
388+
257389function buildTriplets ( services : ExternalServiceRow [ ] ) : Triplet [ ] {
258390 return services . map ( ( row ) => {
259391 const entry = toStatusPageEntry ( row ) ;
@@ -265,30 +397,37 @@ function buildTriplets(services: ExternalServiceRow[]): Triplet[] {
265397export async function runExternalStatusTick ( ) : Promise < {
266398 status : PhaseCounts ;
267399 incidents : PhaseCounts ;
400+ components : PhaseCounts ;
268401} > {
269402 const services = await listExternalServices ( { ctx : { db } } ) ;
270403
271404 const triplets = buildTriplets ( services ) ;
272405 const tickStartedAt = new Date ( ) ;
273406
274- const [ statusOutcomes , incidentOutcomes ] = await Effect . runPromise (
275- Effect . all (
276- [
277- runStatusPhase ( triplets , tickStartedAt . getTime ( ) ) ,
278- runIncidentPhase ( triplets , tickStartedAt ) ,
279- ] ,
280- { concurrency : "unbounded" } ,
281- ) ,
282- ) ;
407+ const [ statusOutcomes , incidentOutcomes , componentOutcomes ] =
408+ await Effect . runPromise (
409+ Effect . all (
410+ [
411+ runStatusPhase ( triplets , tickStartedAt . getTime ( ) ) ,
412+ runIncidentPhase ( triplets , tickStartedAt ) ,
413+ runComponentPhase ( triplets , tickStartedAt , tickStartedAt . getTime ( ) ) ,
414+ ] ,
415+ { concurrency : "unbounded" } ,
416+ ) ,
417+ ) ;
283418
284419 const status = summarizeStatus ( statusOutcomes ) ;
285420 const incidents = summarizeIncidents ( incidentOutcomes ) ;
421+ const components = summarizeComponents ( componentOutcomes ) ;
286422
287423 if ( status . snapshots . length > 0 ) {
288424 await tb . publishExternalStatus ( status . snapshots ) ;
289425 }
426+ if ( components . snapshots . length > 0 ) {
427+ await tb . publishExternalStatusComponent ( components . snapshots ) ;
428+ }
290429
291- return { status : status . counts , incidents } ;
430+ return { status : status . counts , incidents, components : components . counts } ;
292431}
293432
294433export async function handleExternalStatusCron ( c : Context ) {
@@ -309,7 +448,7 @@ export async function handleExternalStatusCron(c: Context) {
309448 Effect . tap ( ( res ) =>
310449 Effect . sync ( ( ) => {
311450 logger . info (
312- "external-status tick complete: status={statusOk}/{statusTotal} ({statusFail} failures, {statusSkip} skipped), incidents={incOk}/{incTotal} ({incFail} failures, {incSkip} skipped)" ,
451+ "external-status tick complete: status={statusOk}/{statusTotal} ({statusFail} failures, {statusSkip} skipped), incidents={incOk}/{incTotal} ({incFail} failures, {incSkip} skipped), components={compOk}/{compTotal} ({compFail} failures, {compSkip} skipped) " ,
313452 {
314453 statusOk : res . status . successCount ,
315454 statusTotal : res . status . total ,
@@ -319,6 +458,10 @@ export async function handleExternalStatusCron(c: Context) {
319458 incTotal : res . incidents . total ,
320459 incFail : res . incidents . failureCount ,
321460 incSkip : res . incidents . skippedCount ,
461+ compOk : res . components . successCount ,
462+ compTotal : res . components . total ,
463+ compFail : res . components . failureCount ,
464+ compSkip : res . components . skippedCount ,
322465 } ,
323466 ) ;
324467 void cronCompleted ( ) ;
0 commit comments