Commit 7b880ea410

7b880ea410f37a7df22594ed9b268448621db4d8

parent: 8b661d9f36

Unsigned

cmc <hello@cleberg.net> · 2026-07-20 20:33 UTC

fix: report unreachable domains instead of 'No meaningful changes'

Closes #10.

resolvedSnapshotAfterFallback replaces a failed lookup's snapshot with
the previous one, so alertDescriptor compared the old snapshot against
itself, found matching hashes, and the run reported 'No meaningful
changes' for a domain that was never actually reached. Nothing in the
UI or the monitoring log distinguished that from a genuine no-change.

MonitoringDomainResult now carries unreachableReason, set when the
fallback fires. It is Optional so already-persisted monitoring logs
still decode. The run summary reads 'Could not check — kept the previous
result' with the underlying error, and monitoringEvents emits a warning-
severity monitoringFailure so configured integrations hear about it
rather than seeing silence.

Layout: unified · split

DomainDig/DomainMonitoringService.swift +41 −2
@@ -291,6 +291,12 @@ final class DomainMonitoringService {
291 previousSnapshot: previousSnapshot 291 previousSnapshot: previousSnapshot
292 ) 292 )
293 let snapshot = Self.resolvedSnapshotAfterFallback(inspectedSnapshot, previousSnapshot: previousSnapshot) 293 let snapshot = Self.resolvedSnapshotAfterFallback(inspectedSnapshot, previousSnapshot: previousSnapshot)
294 // When the fallback fires, `snapshot` *is* `previousSnapshot`, so
295 // every comparison below is old-against-old and would otherwise
296 // report "No meaningful changes" for a domain we never reached.
297 let unreachableReason = (previousSnapshot != nil && Self.shouldFallbackToSnapshot(inspectedSnapshot))
298 ? Self.firstErrorMessage(in: inspectedSnapshot)
299 : nil
294 let savedEntry: HistoryEntry? 300 let savedEntry: HistoryEntry?
295 if snapshot.statusMessage == nil { 301 if snapshot.statusMessage == nil {
296 savedEntry = persistSnapshot( 302 savedEntry = persistSnapshot(
@@ -330,14 +336,16 @@ final class DomainMonitoringService {
330 historyEntryID: savedEntry?.id ?? snapshot.historyEntryID, 336 historyEntryID: savedEntry?.id ?? snapshot.historyEntryID,
331 checkedAt: now, 337 checkedAt: now,
332 didChange: alertDescriptor != nil, 338 didChange: alertDescriptor != nil,
333 summaryMessage: snapshot.statusMessage 339 summaryMessage: unreachableReason.map { "Could not check — kept the previous result. \($0)" }
340 ?? snapshot.statusMessage
334 ?? alertDescriptor?.message 341 ?? alertDescriptor?.message
335 ?? savedEntry?.changeSummary?.message 342 ?? savedEntry?.changeSummary?.message
336 ?? "No meaningful changes", 343 ?? "No meaningful changes",
337 alertSeverity: alertDescriptor?.severity, 344 alertSeverity: alertDescriptor?.severity,
338 certificateWarningLevel: DomainDiffService.certificateWarningLevel(for: snapshot), 345 certificateWarningLevel: DomainDiffService.certificateWarningLevel(for: snapshot),
339 resultSource: snapshot.resultSource, 346 resultSource: snapshot.resultSource,
340 errorMessage: snapshot.statusMessage 347 errorMessage: snapshot.statusMessage,
348 unreachableReason: unreachableReason
341 ) 349 )
342 results.append(result) 350 results.append(result)
343 351
@@ -397,6 +405,21 @@ final class DomainMonitoringService {
397 ) 405 )
398 } 406 }
399 407
408 if let unreachableReason = result.unreachableReason {
409 return MonitoringEvent(
410 type: .monitoringFailure,
411 severity: .warning,
412 domain: result.domain,
413 timestamp: result.checkedAt,
414 summary: result.summaryMessage,
415 details: [
416 "reason": unreachableReason,
417 "trigger": log.trigger.rawValue,
418 "resultSource": result.resultSource.rawValue
419 ]
420 )
421 }
422
400 if result.certificateWarningLevel == .critical { 423 if result.certificateWarningLevel == .critical {
401 return MonitoringEvent( 424 return MonitoringEvent(
402 type: .certificateExpiring, 425 type: .certificateExpiring,
@@ -943,6 +966,22 @@ final class DomainMonitoringService {
943 ) 966 )
944 } 967 }
945 968
969 /// First reported error on a snapshot, used to explain a fallback. Mirrors
970 /// the ordering `shouldFallbackToSnapshot` inspects.
971 private static func firstErrorMessage(in snapshot: LookupSnapshot) -> String {
972 [
973 snapshot.dnsError,
974 snapshot.httpHeadersError,
975 snapshot.sslError,
976 snapshot.ownershipError,
977 snapshot.subdomainsError,
978 snapshot.redirectChainError,
979 snapshot.ipGeolocationError
980 ]
981 .compactMap { $0 }
982 .first ?? "The lookup could not reach the domain."
983 }
984
946 private static func shouldFallbackToSnapshot(_ snapshot: LookupSnapshot) -> Bool { 985 private static func shouldFallbackToSnapshot(_ snapshot: LookupSnapshot) -> Bool {
947 let candidateMessages = [ 986 let candidateMessages = [
948 snapshot.dnsError, 987 snapshot.dnsError,
DomainDig/Models.swift +7 −1
@@ -1544,6 +1544,10 @@ struct MonitoringDomainResult: Codable, Identifiable, Equatable {
1544 let certificateWarningLevel: CertificateWarningLevel 1544 let certificateWarningLevel: CertificateWarningLevel
1545 let resultSource: LookupResultSource 1545 let resultSource: LookupResultSource
1546 let errorMessage: String? 1546 let errorMessage: String?
1547 /// Non-nil when the lookup failed and the previous snapshot was reused, so
1548 /// this run compared the old data against itself and cannot claim the
1549 /// domain is unchanged. Optional so already-persisted logs still decode.
1550 let unreachableReason: String?
1547 1551
1548 init( 1552 init(
1549 id: UUID = UUID(), 1553 id: UUID = UUID(),
@@ -1555,7 +1559,8 @@ struct MonitoringDomainResult: Codable, Identifiable, Equatable {
1555 alertSeverity: MonitoringAlertSeverity?, 1559 alertSeverity: MonitoringAlertSeverity?,
1556 certificateWarningLevel: CertificateWarningLevel, 1560 certificateWarningLevel: CertificateWarningLevel,
1557 resultSource: LookupResultSource, 1561 resultSource: LookupResultSource,
1558 errorMessage: String? = nil 1562 errorMessage: String? = nil,
1563 unreachableReason: String? = nil
1559 ) { 1564 ) {
1560 self.id = id 1565 self.id = id
1561 self.domain = domain 1566 self.domain = domain
@@ -1567,6 +1572,7 @@ struct MonitoringDomainResult: Codable, Identifiable, Equatable {
1567 self.certificateWarningLevel = certificateWarningLevel 1572 self.certificateWarningLevel = certificateWarningLevel
1568 self.resultSource = resultSource 1573 self.resultSource = resultSource
1569 self.errorMessage = errorMessage 1574 self.errorMessage = errorMessage
1575 self.unreachableReason = unreachableReason
1570 } 1576 }
1571} 1577}
1572 1578