-
Notifications
You must be signed in to change notification settings - Fork 53
Expand file tree
/
Copy pathAnomalies.hs
More file actions
1915 lines (1736 loc) · 110 KB
/
Copy pathAnomalies.hs
File metadata and controls
1915 lines (1736 loc) · 110 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
module Pages.Anomalies (
anomalyListGetH,
anomalyBulkActionsPostH,
escapedQueryPartial,
acknowledgeAnomalyGetH,
unAcknowledgeAnomalyGetH,
archiveAnomalyGetH,
unArchiveAnomalyGetH,
anomalyDetailGetH,
AnomalyBulkForm (..),
AnomalyListGet (..),
anomalyAcknowledgeButton,
anomalyArchiveButton,
anomalyDetailHashGetH,
AnomalyAction (..),
IssueVM (..),
issueColumns,
AssignErrorForm (..),
assignErrorPostH,
resolveErrorPostH,
ErrorSubscriptionForm (..),
errorSubscriptionPostH,
-- AI Chat
AIChatForm (..),
aiChatPostH,
aiChatHistoryGetH,
-- Activity
issueActivityGetH,
-- Pattern group members
errorGroupMembersGetH,
errorUnmergePostH,
-- Shared rendering helpers
issueCardCompact_,
)
where
import BackgroundJobs qualified
import Data.Aeson qualified as AE
import Data.Aeson.Types (Parser, parseMaybe)
import Data.CaseInsensitive qualified as CI
import Data.Default (def)
import Data.Effectful.Hasql qualified as Hasql
import Data.HashMap.Strict qualified as HM
import Data.List.NonEmpty qualified as NE
import Data.Map qualified as Map
import Data.Ord (clamp)
import Data.Pool (withResource)
import Data.Text qualified as T
import Data.Text.Display (display)
import Data.Time (UTCTime, addUTCTime, defaultTimeLocale, diffUTCTime, formatTime, getCurrentTime)
import Data.Time.Clock.POSIX qualified as POSIX
import Data.Time.LocalTime (ZonedTime, zonedTimeToUTC)
import Data.UUID qualified as UUID
import Data.Vector qualified as V
import Database.PostgreSQL.Simple.Newtypes (Aeson (..), getAeson)
import Deriving.Aeson qualified as DAE
import Effectful.Concurrent.Async (concurrently)
import Effectful.Error.Static (throwError)
import Effectful.Reader.Static (ask)
import Effectful.Time qualified as Time
import GHC.Records (HasField)
import Hasql.Interpolate qualified as HI
import Lucid
import Lucid.Aria qualified as Aria
import Lucid.Base (TermRaw (termRaw), makeAttribute)
import Lucid.Htmx (hxGet_, hxIndicator_, hxPost_, hxSwap_, hxTarget_, hxTrigger_)
import Lucid.Hyperscript (__)
import Models.Apis.Anomalies qualified as Anomalies
import Models.Apis.Endpoints qualified as Endpoints
import Models.Apis.ErrorPatterns (ErrorPatternId (..))
import Models.Apis.ErrorPatterns qualified as ErrorPatterns
import Models.Apis.Fields (FacetData (..), FacetSummary (..), FacetValue (..))
import Models.Apis.Fields qualified as Fields
import Models.Apis.Issues qualified as Issues
import Models.Apis.LogPatterns (sourceFieldLabel)
import Models.Apis.Monitors qualified as Monitors
import Models.Apis.PatternMerge qualified as PatternMerge
import Models.Projects.ProjectMembers qualified as ProjectMembers
import Models.Projects.Projects (User (id))
import Models.Projects.Projects qualified as Projects
import Models.Telemetry.Schema qualified as Schema
import Models.Telemetry.Telemetry qualified as Telemetry
import OddJobs.Job (createJob)
import Pages.BodyWrapper (BWConfig (..), PageCtx (..), mkPageCtx, navTabAttrs)
import Pages.Charts.Charts qualified as Charts
import Pages.Components (colorChip_, compactTimeAgo, metadataChip_, periodToggle_, resizer_, sparkline_)
import Pages.LogExplorer.Log (virtualTable)
import Pages.Telemetry (tracePage)
import Pkg.AI qualified as AI
import Pkg.Components.Table (BulkAction (..), Column (..), Config (..), Features (..), FilterMenu (..), FilterOption (..), Pagination (..), SearchMode (..), TabFilter (..), TabFilterOpt (..), Table (..), TableHeaderActions (..), TableRows (..), ZeroState (..), col, withAttrs, withColHeaderExtra)
import Pkg.Components.TimePicker qualified as TimePicker
import Pkg.Components.Widget qualified as Widget
import Pkg.DeriveUtils (UUIDId (..), hashAssetFile)
import PyF (fmt)
import Relude hiding (ask)
import Relude.Unsafe qualified as Unsafe
import Servant (err400, errBody)
import System.Config (AuthContext (..), EnvConfig (..))
import System.Types (ATAuthCtx, RespHeaders, addErrorToast, addRespHeaders, addSuccessToast, addTriggerEvent)
import Text.Time.Pretty (prettyTimeAuto)
import Utils (LoadingSize (..), LoadingType (..), checkFreeTierStatus, escapedQueryPartial, faSprite_, formatOffset, formatUTC, formatWithCommas, htmxOverlayIndicator_, loadingIndicator_, lookupValueText, renderMarkdown, toUriStr)
import Web.FormUrlEncoded (FromForm)
newtype AnomalyBulkForm = AnomalyBulk
{ itemId :: [Text]
}
deriving stock (Generic, Show)
deriving anyclass (FromForm)
acknowledgeAnomalyGetH :: Projects.ProjectId -> Anomalies.AnomalyId -> Maybe Text -> ATAuthCtx (RespHeaders AnomalyAction)
acknowledgeAnomalyGetH pid aid hostM = do
(sess, project) <- Projects.sessionAndProject pid
appCtx <- ask @AuthContext
let issueId = UUIDId aid.unUUIDId
_ <- Issues.acknowledgeIssue issueId sess.user.id
Issues.logIssueActivity issueId Issues.IEAcknowledged (Just sess.user.id) Nothing
let text_id = V.fromList [UUID.toText aid.unUUIDId]
v <- Anomalies.acknowledgeAnomalies sess.user.id text_id
_ <- Anomalies.acknowlegeCascade sess.user.id (V.fromList v)
addRespHeaders $ Acknowlege pid (UUIDId aid.unUUIDId) True
unAcknowledgeAnomalyGetH :: Projects.ProjectId -> Anomalies.AnomalyId -> ATAuthCtx (RespHeaders AnomalyAction)
unAcknowledgeAnomalyGetH pid aid = do
(sess, project) <- Projects.sessionAndProject pid
_ <- Hasql.interpExecute [HI.sql| update apis.issues set acknowledged_by=null, acknowledged_at=null where id=#{aid} |]
_ <- Hasql.interpExecute [HI.sql| update apis.anomalies set acknowledged_by=null, acknowledged_at=null where id=#{aid} |]
Issues.logIssueActivity (UUIDId aid.unUUIDId) Issues.IEUnacknowledged (Just sess.user.id) Nothing
addRespHeaders $ Acknowlege pid (UUIDId aid.unUUIDId) False
archiveAnomalyGetH :: Projects.ProjectId -> Anomalies.AnomalyId -> ATAuthCtx (RespHeaders AnomalyAction)
archiveAnomalyGetH pid aid = do
(sess, project) <- Projects.sessionAndProject pid
now <- Time.currentTime
_ <- Hasql.interpExecute [HI.sql| update apis.issues set archived_at=#{now} where id=#{aid} |]
_ <- Hasql.interpExecute [HI.sql| update apis.anomalies set archived_at=#{now} where id=#{aid} |]
Issues.logIssueActivity (UUIDId aid.unUUIDId) Issues.IEArchived (Just sess.user.id) Nothing
addRespHeaders $ Archive pid (UUIDId aid.unUUIDId) True
unArchiveAnomalyGetH :: Projects.ProjectId -> Anomalies.AnomalyId -> ATAuthCtx (RespHeaders AnomalyAction)
unArchiveAnomalyGetH pid aid = do
(sess, project) <- Projects.sessionAndProject pid
_ <- Hasql.interpExecute [HI.sql| update apis.issues set archived_at=null where id=#{aid} |]
_ <- Hasql.interpExecute [HI.sql| update apis.anomalies set archived_at=null where id=#{aid} |]
Issues.logIssueActivity (UUIDId aid.unUUIDId) Issues.IEUnarchived (Just sess.user.id) Nothing
addRespHeaders $ Archive pid (UUIDId aid.unUUIDId) False
data AnomalyAction
= Acknowlege Projects.ProjectId Issues.IssueId Bool
| Archive Projects.ProjectId Issues.IssueId Bool
| Bulk
instance ToHtml AnomalyAction where
toHtml (Acknowlege pid aid is_ack) = toHtml $ anomalyAcknowledgeButton pid aid is_ack ""
toHtml (Archive pid aid is_arch) = toHtml $ anomalyArchiveButton pid aid is_arch
toHtml Bulk = ""
toHtmlRaw = toHtml
-- | Bulk acknowledge/archive anomalies, triggering a notification and list reload
anomalyBulkActionsPostH :: Projects.ProjectId -> Text -> AnomalyBulkForm -> ATAuthCtx (RespHeaders AnomalyAction)
anomalyBulkActionsPostH pid action items = do
(sess, project) <- Projects.sessionAndProject pid
appCtx <- ask @AuthContext
if null items.itemId
then do
addErrorToast "No items selected" Nothing
addRespHeaders Bulk
else do
let vIds = V.fromList items.itemId
eventType <- case action of
"acknowledge" -> do
ths <- Anomalies.acknowledgeAnomalies sess.user.id vIds
void $ Anomalies.acknowlegeCascade sess.user.id (V.fromList ths)
pure Issues.IEAcknowledged
"archive" -> do
void $ Anomalies.archiveAnomaliesAndIssues vIds
pure Issues.IEArchived
_ -> throwError err400{errBody = "unhandled anomaly bulk action: " <> encodeUtf8 action}
forM_ items.itemId \iid -> case UUID.fromText iid of
Just u -> Issues.logIssueActivity (UUIDId u) eventType (Just sess.user.id) Nothing
Nothing -> pass
addSuccessToast (action <> "d items Successfully") Nothing
addTriggerEvent "issuesListChanged" AE.Null
addRespHeaders Bulk
anomalyDetailGetH :: Projects.ProjectId -> Issues.IssueId -> Maybe Text -> Maybe Text -> ATAuthCtx (RespHeaders (PageCtx (Html ())))
anomalyDetailGetH pid issueId firstM sinceM =
anomalyDetailCore pid firstM sinceM $ \_ ->
Issues.selectIssueById issueId
anomalyDetailHashGetH :: Projects.ProjectId -> Text -> Maybe Text -> Maybe Text -> ATAuthCtx (RespHeaders (PageCtx (Html ())))
anomalyDetailHashGetH pid issueId firstM sinceM = anomalyDetailCore pid firstM sinceM \_ -> Issues.selectIssueByHash pid issueId
anomalyDetailCore :: Projects.ProjectId -> Maybe Text -> Maybe Text -> (Projects.ProjectId -> ATAuthCtx (Maybe Issues.Issue)) -> ATAuthCtx (RespHeaders (PageCtx (Html ())))
anomalyDetailCore pid firstM sinceM fetchIssue = do
(sess, project, bw) <- mkPageCtx pid
issueM <- fetchIssue pid
now <- Time.currentTime
let baseBwconf = bw{pageTitle = "Issues", menuItem = Just "Issues"}
case issueM of
Nothing -> do
addErrorToast "Issue not found" Nothing
addRespHeaders
$ PageCtx baseBwconf
$ toHtml ("Issue not found" :: Text)
Just issue -> do
let tp = TimePicker.TimePicker (Just $ fromMaybe (defaultSinceRange issue.createdAt now) sinceM) Nothing Nothing
errorM <-
issue.issueType & \case
Issues.RuntimeException -> ErrorPatterns.getErrorPatternLByHash pid issue.targetHash now
_ -> pure Nothing
(members, canResolve) <- case errorM of
Just _ -> do
userPermission <- ProjectMembers.getUserPermission pid sess.user.id
ms <- V.fromList <$> ProjectMembers.selectActiveProjectMembers pid
let cr =
userPermission
>= Just ProjectMembers.PEdit
|| maybe False (\errL -> errL.base.assigneeId == Just sess.user.id) errorM
pure (ms, cr)
Nothing -> pure (V.empty, False)
let bwconf =
baseBwconf
{ prePageTitle = Just "Issues"
, pageTitle = "#" <> show issue.seqNum
, headContent = Just do highlightJsHead_; style_ "#crisp-chatbox { display: none !important; }"
, pageActions = Just $ div_ [class_ "flex gap-2"] do
anomalyAcknowledgeButton pid (UUIDId issue.id.unUUIDId) (isJust issue.acknowledgedAt) ""
anomalyArchiveButton pid (UUIDId issue.id.unUUIDId) (isJust issue.archivedAt)
when (issue.issueType == Issues.RuntimeException)
$ whenJust errorM \errL -> do
errorResolveAction pid errL.base.id errL.base.state canResolve
errorSubscriptionAction pid errL.base
}
let isFirst = isJust firstM
mTraceId <- case issue.issueType of
Issues.RuntimeException ->
pure $ errorM >>= \errL -> bool errL.base.recentTraceId errL.base.firstTraceId isFirst
Issues.ApiChange -> case AE.fromJSON (getAeson issue.issueData) of
AE.Success (d :: Issues.APIChangeData) ->
Telemetry.getEndpointTraceId pid d.endpointMethod d.endpointPath isFirst now
_ -> pure Nothing
_ -> pure Nothing
(trItem, spanRecs) <-
fromMaybe (Nothing, V.empty) <$> runMaybeT do
tId <- hoistMaybe mTraceId
traceItem <- MaybeT $ Telemetry.getTraceDetails pid tId Nothing now
otelLogs <- lift $ Telemetry.getSpanRecordsByTraceId pid traceItem.traceId (Just traceItem.traceStartTime) now
pure (Just traceItem, V.catMaybes $ Telemetry.convertOtelLogsAndSpansToSpanRecord <$> V.fromList otelLogs)
sampleOverride <-
if issue.issueType `elem` [Issues.LogPattern, Issues.LogPatternRateChange]
then do
let pidTxt = pid.toText
patHash = "pat:" <> issue.targetHash
windowStart = addUTCTime (-(7 * 24 * 3600)) now
rows :: [V.Vector Text] <-
Hasql.interp
[HI.sql| SELECT summary FROM otel_logs_and_spans
WHERE project_id = #{pidTxt}
AND timestamp BETWEEN #{windowStart} AND #{now}
AND #{patHash} = ANY(hashes)
ORDER BY timestamp DESC
LIMIT 1 |]
pure $ viaNonEmpty head rows
else pure Nothing
addRespHeaders $ PageCtx bwconf $ anomalyDetailPage pid issue trItem spanRecs errorM now (isJust firstM) members tp sampleOverride
-- | Render a span/log @summary@ array element-wise as styled chips. Each
-- element stays as a single chip even if its value contains internal
-- whitespace (user-agent, page title). Right-rail metadata renders with a
-- subdued style so the primary fields stay visually dominant.
renderSummaryChips_ :: Monad m => V.Vector Text -> HtmlT m ()
renderSummaryChips_ summary = V.forM_ summary \token ->
case T.breakOn "\8658" token of
(_, "") -> span_ [class_ "text-textWeak text-xs whitespace-pre-wrap break-words"] $ toHtml $ unesc token
(left, rest) -> do
let value = unesc $ T.drop 1 rest
(field, style) = case T.breakOn ";" left of
(f, s) | not (T.null s) -> (f, T.drop 1 s)
_ -> ("", left)
baseStyle = fromMaybe style $ T.stripPrefix "right-" style
cls = case baseStyle of
s | "badge-" `T.isPrefixOf` s -> "cbadge-sm " <> s <> " whitespace-pre-wrap break-all"
"neutral" -> "cbadge-sm badge-neutral whitespace-pre-wrap break-all"
"text-textWeak" -> "text-textWeak text-xs whitespace-pre-wrap break-words"
"text-weak" -> "text-textWeak text-xs whitespace-pre-wrap break-words"
"text-textStrong" -> "text-textStrong text-xs font-medium whitespace-pre-wrap break-words"
_ -> "cbadge-sm badge-neutral whitespace-pre-wrap break-all"
tipAttr = [term "data-tippy-content" field | not (T.null field)]
span_ ([class_ $ cls <> " inline-block max-w-full"] <> tipAttr) $ toHtml value
where
unesc = T.replace "\\\"" "\"" . T.replace "\\n" " " . T.replace "\\t" " "
-- | Smart default time range based on anomaly age.
-- Picks a range ~2x the anomaly age so the data fills the chart.
defaultSinceRange :: ZonedTime -> UTCTime -> Text
defaultSinceRange createdAt now
| ageH < 1 = "1H"
| ageH < 3 = "3H"
| ageH < 6 = "6H"
| ageH < 24 = "24H"
| ageH < 72 = "3D"
| ageH < 168 = "7D"
| otherwise = "14D"
where
ageH = diffUTCTime now (zonedTimeToUTC createdAt) / 3600
-- | A single user-journey event (Sentry-style) attached to a span as a JSON-encoded array
-- under the @breadcrumbs@ attribute. @kind@/@payload@ stand in for the JSON keys @type@/@data@
-- (renamed because @type_@ would clash with @Lucid.type_@ since field selectors are enabled).
data Breadcrumb = Breadcrumb
{ kind :: Text
, message :: Maybe Text
, payload :: Maybe AE.Value
, timestamp :: Integer
}
deriving stock (Generic, Show)
deriving
(AE.FromJSON)
via DAE.CustomJSON
'[ DAE.OmitNothingFields
, DAE.FieldLabelModifier '[DAE.Rename "kind" "type", DAE.Rename "payload" "data"]
]
Breadcrumb
-- | Convert a UTCTime to epoch-milliseconds (the unit used by Breadcrumb).
utcToEpochMs :: UTCTime -> Integer
utcToEpochMs = floor . (* 1000) . POSIX.utcTimeToPOSIXSeconds
-- | Source 1: legacy Sentry-style stringified JSON array under @attributes.breadcrumbs@.
breadcrumbsFromCustomAttr :: Telemetry.SpanRecord -> [Breadcrumb]
breadcrumbsFromCustomAttr sr = fromMaybe [] do
raw <- Telemetry.atMapText "breadcrumbs" sr.attributes
AE.decodeStrict (encodeUtf8 raw)
-- | Source 2: OTel-native span events. Sentry's modern OTel SDK records breadcrumbs as
-- span events with attribute keys prefixed @sentry.breadcrumb.*@; we also handle plain
-- OTel events (using the event name as the kind and @attributes.message\/body@ as the message).
breadcrumbsFromSpanEvents :: Telemetry.SpanRecord -> [Breadcrumb]
breadcrumbsFromSpanEvents sr = case AE.fromJSON sr.events of
AE.Success (events :: [Telemetry.SpanEvent]) -> map toBreadcrumb events
_ -> []
where
toBreadcrumb ev =
let attrs = ev.eventAttributes
sentryKind = lookupValueText attrs "sentry.breadcrumb.category" <|> lookupValueText attrs "sentry.breadcrumb.type"
msg =
asum
$ lookupValueText attrs
<$> ["sentry.breadcrumb.message", "message", "body", "exception.message"]
in Breadcrumb
{ kind = fromMaybe ev.eventName sentryKind
, message = msg
, payload = Just attrs
, timestamp = utcToEpochMs ev.eventTime
}
-- | Source 3: trace-scoped log records — every record in the trace that isn't the error
-- span itself. For backend traces this surfaces "user logged in -> queried db -> exception"
-- without any custom instrumentation.
breadcrumbsFromTraceLogs :: Text -> Telemetry.SpanRecord -> [Breadcrumb]
breadcrumbsFromTraceLogs errorSpanId sr =
[ Breadcrumb
{ kind = sr.spanName
, message = sr.statusMessage <|> Telemetry.atMapText "body" sr.attributes <|> Telemetry.atMapText "message" sr.attributes
, payload = AE.toJSON <$> sr.attributes
, timestamp = utcToEpochMs sr.startTime
}
| sr.spanId /= errorSpanId
]
-- | Combine all breadcrumb sources for a trace, dedupe near-duplicates emitted by
-- overlapping instrumentation (e.g. Sentry SDK that ships both legacy attr + OTel events),
-- and sort chronologically. Duplicates land adjacent after sorting on the dedup key,
-- so 'groupBy' suffices.
extractBreadcrumbs :: V.Vector Telemetry.SpanRecord -> Maybe (NonEmpty Breadcrumb)
extractBreadcrumbs spans =
let recs = V.toList spans
errorSpanId = maybe "" (.spanId) $ viaNonEmpty last $ sortOn (.startTime) recs
raw =
concatMap breadcrumbsFromCustomAttr recs
<> concatMap breadcrumbsFromSpanEvents recs
<> concatMap (breadcrumbsFromTraceLogs errorSpanId) recs
dedupKey bc = (bc.timestamp, bc.kind, T.take 80 $ fromMaybe "" bc.message)
deduped = map head $ NE.groupBy ((==) `on` dedupKey) $ sortOn dedupKey raw
in nonEmpty deduped
-- | Icon id + tailwind colour class for a breadcrumb @type@.
breadcrumbVisual :: Text -> (Text, Text)
breadcrumbVisual t = case t of
"click" -> ("arrow-pointer", "text-fillBrand-strong")
"navigation" -> ("globe", "text-fillSuccess-strong")
"nav" -> ("globe", "text-fillSuccess-strong")
"xhr" -> ("wifi", "text-fillInformation-strong")
"fetch" -> ("wifi", "text-fillInformation-strong")
"console.error" -> ("terminal", "text-fillError-strong")
"console.warn" -> ("terminal", "text-fillWarning-strong")
_ -> ("terminal", "text-textWeak")
-- | Compact selector / url summary from a breadcrumb's @data@ blob.
breadcrumbDataSummary :: AE.Value -> Maybe Text
breadcrumbDataSummary (AE.String s) = Just s
breadcrumbDataSummary v = asum $ lookupValueText v <$> ["selector", "url"]
-- | User-journey breadcrumb section, rendered inline inside an existing card.
-- Emits nothing when the trace carries no parseable breadcrumbs.
userJourneySection_ :: V.Vector Telemetry.SpanRecord -> Html ()
userJourneySection_ spans = whenJust (extractBreadcrumbs spans) \crumbs -> do
let crumbList = toList crumbs
total = length crumbList
base = (head crumbs).timestamp
lastIdx = total - 1
renderCrumb idx bc = do
let (icn, iconColor) = breadcrumbVisual bc.kind
isTerminal = idx == lastIdx
rowCls =
if isTerminal
then "relative flex gap-2.5 px-4 py-2 border-l-2 border-strokeError-strong bg-fillError-weak"
else "relative flex gap-2.5 px-4 py-2 border-l-2 border-transparent hover:bg-fillWeaker"
timeLabel
| idx == 0 = toText $ formatTime defaultTimeLocale "%b %-e, %H:%M:%S" $ POSIX.posixSecondsToUTCTime $ realToFrac (fromIntegral bc.timestamp / 1000 :: Double)
| otherwise = formatOffset base bc.timestamp
div_ [class_ rowCls] do
div_ [class_ "flex flex-col items-center pt-0.5 shrink-0"] do
faSprite_ icn "regular" $ "w-3 h-3 " <> iconColor
unless isTerminal $ div_ [class_ "w-px flex-1 bg-strokeWeak mt-1"] ""
div_ [class_ "min-w-0 flex-1 flex flex-col gap-0.5"] do
div_ [class_ "flex items-center gap-2 flex-wrap"] do
span_ [class_ "text-xs tabular-nums text-textWeak shrink-0"] $ toHtml timeLabel
span_ [class_ $ "text-xs font-medium " <> iconColor] $ toHtml bc.kind
-- Long messages clamp to 3 lines; clicking the row toggles a `expanded` class via hyperscript
-- to remove the clamp. Plain class toggle is more reliable than relying on `details[open]`
-- propagating through Tailwind's named-group `open` variant for a `display:-webkit-box` reset.
let expandable cls val =
div_
[ class_ "group/bc cursor-pointer flex items-start gap-1"
, [__|on click toggle .is-open on me|]
]
do
span_ [class_ $ cls <> " group-[.is-open]/bc:line-clamp-none group-[.is-open]/bc:!block min-w-0 flex-1"] $ toHtml val
faSprite_ "chevron-down" "regular" "w-3 h-3 text-textWeak shrink-0 mt-1 group-[.is-open]/bc:rotate-180 transition-transform"
whenJust bc.message
$ expandable "text-sm text-textStrong line-clamp-3 break-words whitespace-pre-wrap"
whenJust (bc.payload >>= breadcrumbDataSummary)
$ expandable "font-mono text-xs text-textWeak line-clamp-2 break-all"
div_ [class_ "border-t border-strokeWeak"] do
div_ [class_ "px-4 py-2 flex items-center gap-2 bg-fillWeaker/40"] do
faSprite_ "route" "regular" "w-3 h-3 text-textWeak"
span_ [class_ "text-[11px] font-semibold text-textWeak uppercase tracking-wide"] "User journey"
span_ [class_ "text-[11px] text-textWeak"] $ toHtml $ show total <> " event" <> (if total == 1 then "" else "s") <> " before error"
div_ [class_ "max-h-80 overflow-y-auto py-1"]
$ V.imapM_ renderCrumb (V.fromList crumbList)
activityPanel_ :: Projects.ProjectId -> Text -> Text -> V.Vector Telemetry.SpanRecord -> Html ()
activityPanel_ pid issueId extraClass spans = do
let activityUrl = "/p/" <> pid.toText <> "/issues/" <> issueId <> "/activity"
details_ [class_ $ unwords $ filter (not . T.null) ["surface-raised rounded-2xl group/activity overflow-hidden", extraClass], term "open" ""] do
summary_ [class_ "px-4 py-3 flex items-center gap-2 cursor-pointer list-none [&::-webkit-details-marker]:hidden"] do
faSprite_ "clock-rotate-left" "regular" "w-3.5 h-3.5 text-textWeak"
span_ [class_ "text-xs font-semibold text-textWeak uppercase tracking-wide"] "Activity"
faSprite_ "chevron-down" "regular" "w-3 h-3 text-textWeak shrink-0 ml-auto group-open/activity:rotate-180 transition-transform"
userJourneySection_ spans
div_ [class_ "border-t border-strokeWeak"] do
div_ [class_ "px-4 py-2 flex items-center gap-2 bg-fillWeaker/40"] do
faSprite_ "circle-info" "regular" "w-3 h-3 text-textWeak"
span_ [class_ "text-[11px] font-semibold text-textWeak uppercase tracking-wide"] "Issue events"
div_ [id_ "issue-activity", hxGet_ activityUrl, hxTrigger_ "intersect once", hxSwap_ "innerHTML"]
$ div_ [class_ "p-4 flex justify-center"]
$ loadingIndicator_ LdSM LdDots
anomalyDetailPage :: Projects.ProjectId -> Issues.Issue -> Maybe Telemetry.Trace -> V.Vector Telemetry.SpanRecord -> Maybe ErrorPatterns.ErrorPatternL -> UTCTime -> Bool -> V.Vector ProjectMembers.ProjectMemberVM -> TimePicker.TimePicker -> Maybe (V.Vector Text) -> Html ()
anomalyDetailPage pid issue tr spanRecs errM now isFirst members tp sampleOverride = do
let (_, _, currentRange) = TimePicker.parseTimeRange now tp
issueId = UUID.toText issue.id.unUUIDId
sevBase = "inline-flex items-center justify-center rounded-md px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 gap-1 "
severityBadge "critical" = span_ [class_ $ sevBase <> "bg-fillError-weak text-fillError-strong border-2 border-strokeError-strong shadow-sm"] "CRITICAL"
severityBadge "warning" = span_ [class_ $ sevBase <> "bg-fillWarning-weak text-fillWarning-strong border border-strokeWarning-weak shadow-sm"] "WARNING"
severityBadge _ = pass
div_ [class_ "flex h-full overflow-hidden relative group/ai"] do
-- LEFT: scrollable main content
div_ [class_ "flex-1 min-w-0 min-h-0 overflow-y-auto max-md:pt-5 pt-8 max-md:px-3 px-4 pb-8 max-md:space-y-3 space-y-4"] do
-- Header: title
h3_ [class_ "max-md:text-xl text-2xl font-semibold text-textStrong flex flex-wrap items-center gap-1"] $ if "⇒" `T.isInfixOf` issue.title then renderSummaryText_ issue.title else toHtml issue.title
unless (issue.recommendedAction == Issues.defaultRecommendedAction)
$ p_ [class_ "text-sm text-textWeak max-w-3xl"]
$ toHtml issue.recommendedAction
-- Metadata chips + issue type content
let createdChip = colorChip_ "text-fillInformation-strong bg-fillInformation-weak" "calendar" $ "Created " <> toText (prettyTimeAuto now (zonedTimeToUTC issue.createdAt))
-- Prefer a real captured log line (sampleOverride) over the stored
-- sample, which the drain pipeline often normalises down to the same
-- placeholders as the template. The override is the original summary
-- vector — render each element as a single chip so values with
-- internal whitespace (user-agents, page titles) stay intact.
logPatternCards sourceField logPattern sampleMessage = div_ [class_ "flex flex-col gap-4"] do
_ <- div_ [class_ "surface-raised rounded-2xl overflow-hidden"] do
div_ [class_ "px-4 py-3 border-b border-strokeWeak flex items-center gap-2"] do
span_ [class_ "text-xs font-semibold text-textWeak uppercase tracking-wide"] "Log Pattern"
span_ [class_ "badge badge-sm badge-ghost"] $ toHtml $ sourceFieldLabel sourceField
renderLogContent_ logPattern
let renderSample body = div_ [class_ "surface-raised rounded-2xl overflow-hidden"] do
_ <- div_ [class_ "px-4 py-3 border-b border-strokeWeak"] $ span_ [class_ "text-xs font-semibold text-textWeak uppercase tracking-wide"] "Sample Message"
body
case sampleOverride of
Just summary
| not (V.null summary) ->
renderSample $ div_ [class_ "flex flex-wrap items-center gap-1 p-4 max-h-80 overflow-y-auto"] $ renderSummaryChips_ summary
_ -> whenJust (sampleMessage >>= \m -> if T.strip m == T.strip logPattern then Nothing else Just m) \msg ->
renderSample (renderLogContent_ msg)
div_ [class_ "flex flex-wrap gap-2 items-center"] do
severityBadge issue.severity
issueTypeLabel issue.issueType issue.critical
case issue.issueType of
Issues.LogPattern -> withIssueDataH @Issues.LogPatternData issue.issueData \d -> do
logLevelChip_ d.logLevel d.logPattern
metadataChip_ "server" $ fromMaybe "Unknown" d.serviceName
metadataChip_ "tally" $ show d.occurrenceCount <> " occurrences"
metadataChip_ "clock" $ "First seen " <> compactTimeAgo (toText $ prettyTimeAuto now d.firstSeenAt)
Issues.LogPatternRateChange -> withIssueDataH @Issues.LogPatternRateChangeData issue.issueData \d -> do
logLevelChip_ d.logLevel d.logPattern
metadataChip_ "arrow-trend-up" $ display d.changeDirection
metadataChip_ "percent" $ Issues.showPct d.changePercent <> " change"
metadataChip_ "gauge-high" $ Issues.showRate d.currentRatePerHour <> " current"
metadataChip_ "chart-line" $ Issues.showRate d.baselineMean <> " baseline"
Issues.RuntimeException -> pass -- First/Last seen shown in Error Details panel
_ -> createdChip
-- Seed URL params with default time range so standalone chart widgets can read it
script_ [fmt|document.addEventListener('DOMContentLoaded',function(){{if(!new URLSearchParams(location.search).get('since'))window.setParams({{since:'{fromMaybe "1H" tp.since}'}})}});|]
-- Volume chart + issue type content
let volumeChart_ chartTitle = whenJust (Issues.hashPrefix issue.issueType) \prefix -> do
let hashQuery = "hashes[*]==\"" <> prefix <> issue.targetHash <> "\" | summarize count(*) by bin_auto(timestamp)"
refreshId = "anomaly-chart-refresh"
div_ [id_ refreshId, class_ "hidden", term "_" "on submit trigger 'update-query' on window"] ""
div_ [class_ "surface-raised rounded-2xl overflow-hidden"] do
div_ [class_ "px-4 py-2 flex flex-wrap items-center justify-between gap-x-3 gap-y-1 border-b border-strokeWeak"] do
span_ [class_ "text-xs font-semibold text-textWeak uppercase tracking-wide"] $ toHtml chartTitle
div_ [class_ "flex items-center gap-2"] do
TimePicker.timepicker_ (Just refreshId) currentRange Nothing
TimePicker.refreshButton_
div_ [class_ "h-24"]
$ Widget.widget_
(def :: Widget.Widget)
{ Widget.standalone = Just True
, Widget.naked = Just True
, Widget.id = Just $ issueId <> "-pattern-volume"
, Widget.wType = Widget.WTTimeseries
, Widget.showTooltip = Just True
, Widget.query = Just hashQuery
, Widget._projectId = Just issue.projectId
, Widget.hideLegend = Just True
, Widget.hideSubtitle = Just True
}
case issue.issueType of
Issues.LogPattern -> withIssueDataH @Issues.LogPatternData issue.issueData \d ->
div_ [class_ "flex flex-col lg:flex-row gap-4 lg:items-start"] do
div_ [class_ "min-w-0 flex-1 flex flex-col gap-4"] do
volumeChart_ "Pattern Volume"
logPatternCards d.sourceField d.logPattern d.sampleMessage
activityPanel_ pid issueId "lg:w-80 shrink-0" spanRecs
Issues.LogPatternRateChange -> withIssueDataH @Issues.LogPatternRateChangeData issue.issueData \d ->
div_ [class_ "flex flex-col lg:flex-row gap-4 lg:items-start"] do
div_ [class_ "min-w-0 flex-1 flex flex-col gap-4"] do
volumeChart_ "Pattern Volume"
logPatternCards d.sourceField d.logPattern d.sampleMessage
activityPanel_ pid issueId "lg:w-80 shrink-0" spanRecs
Issues.RuntimeException -> withIssueDataH @Issues.RuntimeExceptionData issue.issueData \exceptionData -> do
let trimmedStack = T.strip exceptionData.stackTrace
hasStack = not $ T.null trimmedStack
errorFirstLine = if hasStack then fromMaybe trimmedStack $ viaNonEmpty head $ lines trimmedStack else exceptionData.errorMessage
detailItem (icn, iconColor, lbl, value) = div_ [class_ "flex items-center gap-1.5 whitespace-nowrap"] do
faSprite_ icn "regular" $ "w-3 h-3 " <> iconColor
span_ [class_ "text-xs text-textWeak"] $ toHtml lbl <> ":"
span_ [class_ "text-xs font-medium"] $ toHtml value
-- Chart + Error Details in one row
div_ [class_ "flex flex-col lg:flex-row gap-4 lg:items-start"] do
div_ [class_ "min-w-0 flex-1"] $ volumeChart_ "Error Frequency"
whenJust errM \errL -> do
let err = errL.base
div_ [class_ "lg:w-72 shrink-0 surface-raised rounded-2xl overflow-hidden"] do
div_ [class_ "px-4 py-3 border-b border-strokeWeak flex items-center gap-2"] do
faSprite_ "circle-info" "regular" "w-3.5 h-3.5 text-textWeak"
span_ [class_ "text-xs font-semibold text-textWeak uppercase tracking-wide"] "Error Details"
div_ [class_ "p-4 flex flex-col gap-3"] do
whenJust ((,) <$> exceptionData.requestMethod <*> exceptionData.requestPath) \(method, path) ->
div_ [class_ "mb-1"] do
span_ [class_ $ "relative cbadge-sm badge-" <> method <> " whitespace-nowrap"] $ toHtml method
span_ [class_ "ml-2 text-sm text-textWeak"] $ toHtml path
div_ [class_ "flex flex-wrap items-center gap-x-5 gap-y-2"]
$ forM_
[ ("calendar" :: Text, "text-fillBrand-strong" :: Text, "First seen" :: Text, compactTimeAgo $ toText $ prettyTimeAuto now (zonedTimeToUTC err.createdAt))
, ("calendar" :: Text, "text-fillBrand-strong" :: Text, "Last seen" :: Text, compactTimeAgo $ toText $ prettyTimeAuto now (zonedTimeToUTC err.updatedAt))
]
detailItem
div_ [class_ "flex flex-wrap items-center gap-x-5 gap-y-2"]
$ forM_
[ ("code" :: Text, "text-fillWarning-strong" :: Text, "Stack" :: Text, fromMaybe "Unknown stack" err.errorData.runtime)
, ("server" :: Text, "text-fillSuccess-strong" :: Text, "Service" :: Text, fromMaybe "Unknown service" err.errorData.serviceName)
]
detailItem
-- Stack trace + Activity (Activity column merges User Journey + issue events)
div_ [class_ "flex flex-col lg:flex-row gap-4 lg:items-start"] do
div_ [class_ "min-w-0 flex-1"]
$ details_ [class_ "surface-raised rounded-2xl group/details", term "open" "", term "_" "init if window.innerWidth < 768 remove @open from me"]
$ do
summary_ [class_ "px-4 py-3 flex items-center gap-2 cursor-pointer list-none [&::-webkit-details-marker]:hidden"] do
faSprite_ "code" "regular" "w-3.5 h-3.5 text-textWeak"
span_ [class_ "text-xs font-semibold text-textWeak uppercase tracking-wide shrink-0"] "Stack Trace"
span_ [class_ "text-xs text-fillError-strong truncate min-w-0 flex-1"] $ toHtml errorFirstLine
faSprite_ "chevron-down" "regular" "w-3 h-3 text-textWeak shrink-0 ml-auto group-open/details:rotate-180 transition-transform"
div_ [class_ "border-t border-strokeWeak"] do
-- Full error message first — visible whether or not we have a stack trace, since the
-- summary truncates and the user expanded specifically to read it in full.
unless (T.null exceptionData.errorMessage) $ div_ [class_ "px-4 py-3 border-b border-strokeWeak"] do
span_ [class_ "text-[11px] font-semibold text-textWeak uppercase tracking-wide block mb-1"] "Error message"
pre_ [class_ "text-sm leading-relaxed text-fillError-strong whitespace-pre-wrap break-words font-mono"] $ toHtml exceptionData.errorMessage
if hasStack
then
div_ [class_ "max-h-80 overflow-y-auto"]
$ pre_ [class_ "text-sm leading-relaxed overflow-x-auto whitespace-pre-wrap px-4 py-3"]
$ code_ []
$ toHtml trimmedStack
else div_ [class_ "px-4 py-4 flex items-start gap-2 text-textWeak"] do
faSprite_ "circle-info" "regular" "w-4 h-4 shrink-0 mt-0.5"
span_ [class_ "text-xs"] "No stack trace captured — common for browser console errors. Check the User Journey for the events that led up to it."
activityPanel_ pid issueId "lg:w-80 shrink-0" spanRecs
-- Similar patterns
whenJust errM \errL -> similarPatternsSection_ pid errL.base.id
Issues.QueryAlert -> withIssueDataH @Issues.QueryAlertData issue.issueData \alertData ->
div_ [class_ "mb-4"] do
span_ [class_ "text-xs text-textWeak mb-2 block font-semibold uppercase tracking-wide"] "Query"
div_ [class_ "bg-fillInformation-weak border border-strokeInformation-weak rounded-lg p-3 text-sm font-mono text-fillInformation-strong max-w-2xl overflow-x-auto"] $ toHtml alertData.queryExpression
Issues.ApiChange -> withIssueDataH @Issues.APIChangeData issue.issueData \d -> do
let detailItem (icn, iconColor, lbl, value) = div_ [class_ "flex items-center gap-1.5 whitespace-nowrap"] do
faSprite_ icn "regular" $ "w-3 h-3 " <> iconColor
span_ [class_ "text-xs text-textWeak"] $ toHtml lbl <> ":"
span_ [class_ "text-xs font-medium"] $ toHtml value
fieldChip color f = span_ [class_ $ "font-mono text-xs px-2 py-0.5 rounded bg-fillWeaker " <> color] $ toHtml f
fieldList :: Text -> Text -> Text -> V.Vector Text -> Html ()
fieldList lbl color icn fields
| V.null fields = pass
| otherwise = div_ [class_ "flex flex-col gap-1.5"] do
div_ [class_ "flex items-center gap-1.5"] do
faSprite_ icn "regular" $ "w-3 h-3 " <> color
span_ [class_ $ "text-xs font-semibold uppercase tracking-wide " <> color] $ toHtml lbl
span_ [class_ "text-xs text-textWeak"] $ toHtml $ "(" <> show (V.length fields) <> ")"
pass
div_ [class_ "flex flex-wrap gap-1"] $ V.forM_ fields (fieldChip color)
hasFieldChanges = not (V.null d.newFields) || not (V.null d.deletedFields) || not (V.null d.modifiedFields)
-- Endpoint chip line
div_ [class_ "flex flex-wrap items-center gap-3"] do
span_ [class_ $ "cbadge-sm whitespace-nowrap badge-" <> d.endpointMethod] $ toHtml d.endpointMethod
span_ [class_ "font-mono bg-fillWeaker px-2 py-1 rounded text-sm text-textStrong"] $ toHtml d.endpointPath
span_ [class_ "flex items-center gap-1.5 text-sm text-textWeak"] do
faSprite_ "server" "regular" "h-3 w-3"
toHtml d.endpointHost
-- Chart + endpoint details panel side-by-side
div_ [class_ "flex flex-col lg:flex-row gap-4 lg:items-start"] do
div_ [class_ "min-w-0 flex-1"] $ volumeChart_ "Request Trend"
div_ [class_ "lg:w-72 shrink-0 surface-raised rounded-2xl overflow-hidden"] do
div_ [class_ "px-4 py-3 border-b border-strokeWeak flex items-center gap-2"] do
faSprite_ "circle-info" "regular" "w-3.5 h-3.5 text-textWeak"
span_ [class_ "text-xs font-semibold text-textWeak uppercase tracking-wide"] "Endpoint Details"
div_ [class_ "p-4 flex flex-col gap-3"] do
div_ [class_ "flex flex-wrap items-center gap-x-5 gap-y-2"]
$ forM_
[ ("calendar" :: Text, "text-fillBrand-strong" :: Text, "First seen" :: Text, compactTimeAgo $ toText $ prettyTimeAuto now (zonedTimeToUTC issue.createdAt))
, ("calendar" :: Text, "text-fillBrand-strong" :: Text, "Last seen" :: Text, compactTimeAgo $ toText $ prettyTimeAuto now (zonedTimeToUTC issue.updatedAt))
]
detailItem
div_ [class_ "flex flex-wrap items-center gap-x-5 gap-y-2"]
$ forM_
[ ("server" :: Text, "text-fillSuccess-strong" :: Text, "Service" :: Text, fromMaybe "Unknown service" issue.service)
, ("hashtag" :: Text, "text-fillBrand-strong" :: Text, "Requests" :: Text, formatWithCommas (fromIntegral issue.affectedRequests :: Double))
]
detailItem
-- Field changes (or "new endpoint" hint) + Activity panel
div_ [class_ "flex flex-col lg:flex-row gap-4 lg:items-start"] do
div_ [class_ "min-w-0 flex-1"]
$ if hasFieldChanges
then div_ [class_ "surface-raised rounded-2xl overflow-hidden"] do
div_ [class_ "px-4 py-3 border-b border-strokeWeak flex items-center gap-2"] do
faSprite_ "list-check" "regular" "w-3.5 h-3.5 text-textWeak"
span_ [class_ "text-xs font-semibold text-textWeak uppercase tracking-wide"] "Field Changes"
div_ [class_ "p-4 flex flex-col gap-4"] do
fieldList "New" "text-fillSuccess-strong" "plus" d.newFields
fieldList "Deleted" "text-fillError-strong" "minus" d.deletedFields
fieldList "Modified" "text-fillWarning-strong" "code" d.modifiedFields
else div_ [class_ "surface-raised rounded-2xl px-4 py-6 flex flex-col items-center gap-2 text-center"] do
faSprite_ "rocket" "regular" "w-5 h-5 text-fillBrand-strong"
span_ [class_ "text-sm text-textStrong"] "New endpoint discovered"
span_
[class_ "text-xs text-textWeak max-w-sm"]
"This endpoint started receiving traffic. Inspect the originating request in Investigation below to see headers, body, and call site."
activityPanel_ pid issueId "lg:w-80 shrink-0" spanRecs
let isLogPatternIssue = issue.issueType `elem` [Issues.LogPattern, Issues.LogPatternRateChange]
div_ [class_ "surface-raised rounded-2xl overflow-hidden", id_ "error-details-container", makeAttribute "tabindex" "-1", onkeydown_ "if(event.key==='Escape'&&this.classList.contains('investigation-fullscreen'))document.getElementById('investigation-fullscreen-btn').click()"] do
div_ [class_ "max-md:px-3 px-4 border-b border-strokeWeak flex max-md:flex-col md:items-center md:justify-between"] do
div_ [class_ "flex items-center gap-2 max-md:py-1.5"] do
faSprite_ "magnifying-glass-chart" "regular" "w-3.5 h-3.5 text-textWeak"
h3_ [class_ "text-xs font-semibold text-textWeak uppercase tracking-wide"] "Investigation"
button_ [class_ "p-1 rounded hover:bg-fillWeaker cursor-pointer transition-colors max-md:hidden", Aria.label_ "Toggle fullscreen", id_ "investigation-fullscreen-btn", onclick_ "var c=document.getElementById('error-details-container'),u=this.querySelector('use'),h=u.getAttribute('href');c.classList.toggle('investigation-fullscreen');u.setAttribute('href',c.classList.contains('investigation-fullscreen')?h.replace('#expand','#compress'):h.replace('#compress','#expand'));window.scrollTo({top:0})"] do
faSprite_ "expand" "regular" "w-3 h-3 text-textWeak"
div_ [class_ "flex items-center max-md:overflow-x-auto max-md:-mx-4 max-md:px-4 max-md:pb-1.5"] do
let aUrl = "/p/" <> pid.toText <> "/issues/" <> issueId
navLink (href, isActive, tooltip, lbl) = a_ [href_ href, class_ $ bool "text-textWeak hover:text-textStrong" "text-textBrand font-medium" isActive <> " text-xs py-2.5 max-md:px-2 px-3 cursor-pointer transition-colors", term "data-tippy-content" tooltip] $ toHtml lbl
tabBtn (target, lbl, isActive) = button_ [class_ $ "text-xs py-2.5 max-md:px-2 px-3 cursor-pointer err-tab font-medium" <> bool "" " t-tab-active" isActive, onclick_ $ "navigatable(this, '" <> target <> "', '#error-details-container', 't-tab-active', 'err')"] $ toHtml lbl
forM_ [(aUrl <> "?first_occurrence=true", isFirst, "Show first trace the error occured" :: Text, "First" :: Text), (aUrl, not isFirst, "Show recent trace the error occured" :: Text, "Recent" :: Text)] navLink
span_ [class_ "mx-3 w-px h-4 bg-strokeWeak max-md:mx-2"] pass
forM_ [("#span-content" :: Text, "Trace" :: Text, not isLogPatternIssue), ("#log-content" :: Text, "Logs" :: Text, isLogPatternIssue)] tabBtn
div_ [class_ "max-md:p-1 p-2 w-full overflow-x-hidden investigation-content"] do
div_ [class_ ((if isLogPatternIssue then "hidden " else "") <> "flex flex-col lg:flex-row w-full err-tab-content"), id_ "span-content"] do
div_ [id_ "trace_container", class_ "grow-1 lg:max-w-[80%] lg:w-1/2 lg:min-w-[20%] shrink-1"]
$ maybe
( div_ [class_ "flex flex-col items-center justify-center h-48"] do
faSprite_ "inbox-full" "regular" "w-6 h-6 text-iconNeutral"
span_ [class_ "mt-2 text-sm text-textWeak"] "No trace data available for this issue."
)
(\t -> tracePage pid t spanRecs)
tr
div_ [class_ "transition-opacity duration-200 mx-1 hidden lg:block", id_ "resizer-details_width-wrapper"] $ resizer_ "log_details_container" "details_width" False
div_ [class_ "grow-0 relative shrink-0 overflow-y-auto overflow-x-hidden max-h-[500px] lg:w-1/2 w-c-scroll overflow-y-auto investigation-details", id_ "log_details_container", term "hx-on::after-swap" "if(window.innerWidth<1024)this.scrollIntoView({behavior:'smooth',block:'start'})"] do
htmxOverlayIndicator_ "details_indicator"
whenJust (spanRecs V.!? 0) \sr ->
div_ [hxGet_ $ "/p/" <> pid.toText <> "/log_explorer/" <> sr.uSpanId <> "/" <> formatUTC sr.timestamp <> "/detailed", hxTarget_ "#log_details_container", hxSwap_ "innerHtml", hxTrigger_ "intersect once", hxIndicator_ "#details_indicator", term "hx-sync" "this:replace"] pass
div_ [id_ "log-content", class_ ((if isLogPatternIssue then "" else "hidden ") <> "err-tab-content")] do
let logsTraceId = fromMaybe "" $ asum [errM >>= (.base.recentTraceId), (.traceId) <$> tr]
logsQuery = case Issues.hashPrefix issue.issueType of
Just prefix | isLogPatternIssue -> "hashes[*]==\"" <> prefix <> issue.targetHash <> "\""
_ -> "kind==\"log\" AND context___trace_id==\"" <> logsTraceId <> "\""
virtualTable pid (Just ("/p/" <> pid.toText <> "/log_explorer?json=true&query=" <> toUriStr logsQuery)) Nothing
let withSessionIds = V.catMaybes $ (\sr -> (`lookupValueText` "id") =<< Map.lookup "session" =<< sr.attributes) <$> spanRecs
unless (V.null withSessionIds) $ div_ [class_ "surface-raised rounded-2xl overflow-hidden", id_ "replay-section"] do
div_ [class_ "max-md:px-3 px-4 py-2.5 border-b border-strokeWeak flex items-center gap-2"] do
faSprite_ "video" "regular" "w-3.5 h-3.5 text-textWeak"
h3_ [class_ "text-xs font-semibold text-textWeak uppercase tracking-wide"] "Session Replay"
termRaw "session-replay" [id_ "sessionReplay", term "initialSession" $ V.head withSessionIds, term "consoleOpen" "true", term "fullWidth" "true", class_ "block w-full", term "projectId" pid.toText, term "containerId" "sessionPlayerWrapper"] ("" :: Text)
unless (issue.issueType `elem` [Issues.RuntimeException, Issues.ApiChange, Issues.LogPattern, Issues.LogPatternRateChange]) $ activityPanel_ pid issueId "" V.empty
-- RIGHT: Inline collapsible AI chat panel (checkbox + group-has CSS, persists to localStorage)
input_ [type_ "checkbox", id_ "ai-panel-toggle", class_ "hidden", onchange_ "localStorage.setItem('ai-panel-open', this.checked); if(this.checked) htmx.trigger('#ai-response-container','load-chat')"]
script_ """(function(){var e=document.getElementById('ai-panel-toggle');e.checked=localStorage.getItem('ai-panel-open')==='true';if(e.checked)htmx.trigger('#ai-response-container','load-chat')})()"""
label_ [Lucid.for_ "ai-panel-toggle", class_ "absolute right-0 top-3 z-10 flex items-center gap-1.5 bg-fillBrand-strong text-white px-2 py-2.5 rounded-l-lg cursor-pointer shadow-md hover:opacity-90 transition-opacity group-has-[#ai-panel-toggle:checked]/ai:hidden", Aria.label_ "Open AI Assistant"] do
faSprite_ "sparkles" "regular" "w-3.5 h-3.5"
div_ [class_ "hidden group-has-[#ai-panel-toggle:checked]/ai:block"] $ resizer_ "ai_chat_container" "ai_width" False
div_ [id_ "ai_chat_container", class_ "hidden group-has-[#ai-panel-toggle:checked]/ai:flex w-[420px] shrink-0 h-full overflow-hidden flex-col bg-bgBase border-l border-t border-strokeWeak"] do
div_ [class_ "shrink-0 px-4 py-2.5 border-b border-strokeWeak flex items-center justify-between"] do
div_ [class_ "flex items-center gap-2"] do
faSprite_ "sparkles" "regular" "w-3.5 h-3.5 text-fillBrand-strong"
span_ [class_ "text-xs font-semibold text-textWeak uppercase tracking-wide"] "AI Assistant"
label_ [Lucid.for_ "ai-panel-toggle", class_ "p-1.5 rounded-lg hover:bg-fillWeaker cursor-pointer transition-colors tap-target", Aria.label_ "Close AI Assistant"]
$ faSprite_ "xmark" "regular" "w-3 h-3 text-textWeak"
anomalyAIChatBody_ pid issue.id
errorAssigneeSection :: Projects.ProjectId -> Maybe ErrorPatterns.ErrorPatternId -> Maybe Projects.UserId -> V.Vector ProjectMembers.ProjectMemberVM -> Html ()
errorAssigneeSection pid errIdM assigneeIdM members = do
let isDisabled = isNothing errIdM || V.null members
div_ [id_ "error-assignee", class_ "flex flex-col gap-2 border-t border-strokeWeak pt-3"] do
span_ [class_ "text-xs text-textWeak"] "Assignee"
case errIdM of
Nothing ->
select_ ([class_ "select select-sm w-full", disabled_ "true"] <> [name_ "assigneeId"]) do
option_ [value_ ""] "Unassigned"
Just errId -> do
let actionUrl = "/p/" <> pid.toText <> "/issues/errors/" <> UUID.toText errId.unErrorPatternId <> "/assign"
form_ [hxPost_ actionUrl, hxTarget_ "#error-assignee", hxSwap_ "outerHTML", hxTrigger_ "change"] do
select_
( [class_ "select select-sm w-full", name_ "assigneeId"]
<> [disabled_ "true" | isDisabled]
)
$ do
option_ ([value_ ""] <> [selected_ "true" | isNothing assigneeIdM]) "Unassigned"
forM_ members \member -> do
let memberIdText = UUID.toText $ Projects.getUserId member.userId
fullName = T.strip $ member.first_name <> " " <> member.last_name
emailText = CI.original member.email
label =
if T.null fullName
then emailText
else fullName <> " (" <> emailText <> ")"
option_
([value_ memberIdText] <> [selected_ "true" | assigneeIdM == Just member.userId])
$ toHtml label
errorResolveAction :: Projects.ProjectId -> ErrorPatterns.ErrorPatternId -> ErrorPatterns.ErrorState -> Bool -> Html ()
errorResolveAction pid errId errState canResolve =
when canResolve do
let actionUrl = "/p/" <> pid.toText <> "/issues/errors/" <> UUID.toText errId.unErrorPatternId <> "/resolve"
div_ [id_ "error-resolve-action"] do
if errState == ErrorPatterns.ESResolved
then button_ [class_ "btn btn-sm btn-ghost text-textWeak", disabled_ "true"] do
faSprite_ "circle-check" "regular" "w-4 h-4"
span_ [class_ "max-md:hidden"] "Resolved"
else button_
[ class_ "btn btn-sm btn-ghost gap-1.5 text-textSuccess hover:bg-fillSuccess-weak"
, Aria.label_ "Resolve issue"
, hxPost_ actionUrl
, hxTarget_ "#error-resolve-action"
, hxSwap_ "outerHTML"
]
do
faSprite_ "circle-check" "regular" "w-4 h-4"
span_ [class_ "max-md:hidden"] "Resolve"
errorSubscriptionAction :: (HasField "id" err ErrorPatterns.ErrorPatternId, HasField "notifyEveryMinutes" err Int, HasField "subscribed" err Bool) => Projects.ProjectId -> err -> Html ()
errorSubscriptionAction pid err = do
let isActive = err.subscribed
let notifyEvery = err.notifyEveryMinutes
let actionUrl = "/p/" <> pid.toText <> "/issues/errors/" <> UUID.toText err.id.unErrorPatternId <> "/subscribe"
form_
[ id_ "issue-subscription-action"
, class_ "flex items-center gap-2"
, hxPost_ actionUrl
, hxTarget_ "#issue-subscription-action"
, hxSwap_ "outerHTML"
, hxTrigger_ "change"
]
do
span_ [class_ "text-xs text-textWeak flex items-center gap-1"] do
faSprite_ "bell" "regular" "w-3 h-3"
span_ [class_ "max-md:hidden"] "Notify every"
select_ [class_ "select select-sm max-md:w-20 w-36", name_ "notifyEveryMinutes", Aria.label_ "Notification frequency"] do
option_ ([value_ "0"] <> [selected_ "true" | not isActive]) "Off"
let opts :: [(Int, Text)]
opts = [(10, "10 min"), (20, "20 min"), (30, "30 min"), (60, "1 hr"), (360, "6 hrs"), (1440, "24 hrs")]
forM_ opts \(val, label) ->
option_ ([value_ (show val)] <> [selected_ "true" | isActive && val == notifyEvery]) (toHtml label)
newtype AssignErrorForm = AssignErrorForm
{ assigneeId :: Maybe Text
}
deriving stock (Generic, Show)
deriving anyclass (FromForm)
newtype ErrorSubscriptionForm = ErrorSubscriptionForm
{ notifyEveryMinutes :: Maybe Int
}
deriving stock (Generic, Show)
deriving anyclass (FromForm)
assignErrorPostH :: Projects.ProjectId -> UUID.UUID -> AssignErrorForm -> ATAuthCtx (RespHeaders (Html ()))
assignErrorPostH pid errUuid form = do
(sess, _project) <- Projects.sessionAndProject pid
appCtx <- ask @AuthContext
let errId = ErrorPatterns.ErrorPatternId errUuid
assigneeIdM = form.assigneeId >>= UUID.fromText <&> Projects.UserId
members <- V.fromList <$> ProjectMembers.selectActiveProjectMembers pid
errM <- ErrorPatterns.getErrorPatternById errId
let render eidM aidM = addRespHeaders $ errorAssigneeSection pid eidM aidM members
isMember = all (\uid -> any (\m -> m.userId == uid) members) assigneeIdM
case errM of
Nothing -> addErrorToast "Error not found" Nothing >> render Nothing Nothing
Just err
| err.projectId /= pid -> addErrorToast "Error not found for this project" Nothing >> render (Just err.id) err.assigneeId
| not isMember -> addErrorToast "Assignee must be an active project member" Nothing >> render (Just err.id) err.assigneeId
| assigneeIdM == err.assigneeId -> addSuccessToast "Assignee unchanged" Nothing >> render (Just err.id) err.assigneeId
| otherwise -> do
now <- Time.currentTime
void $ ErrorPatterns.setErrorPatternAssignee err.id assigneeIdM now
whenJust assigneeIdM \assigneeId ->
void $ liftIO $ withResource appCtx.pool \conn ->
createJob conn "background_jobs" $ BackgroundJobs.ErrorAssigned pid err.id assigneeId
issueM <- Issues.selectIssueByHash pid err.hash
let event = maybe Issues.IEUnassigned (const Issues.IEAssigned) assigneeIdM
meta = assigneeIdM <&> \uid -> AE.object ["assignee_id" AE..= uid]
whenJust issueM \issue -> Issues.logIssueActivity issue.id event (Just sess.user.id) meta
addSuccessToast "Assignee updated" Nothing
render (Just err.id) assigneeIdM
resolveErrorPostH :: Projects.ProjectId -> UUID.UUID -> ATAuthCtx (RespHeaders (Html ()))
resolveErrorPostH pid errUuid = do
(sess, _project) <- Projects.sessionAndProject pid
errM <- ErrorPatterns.getErrorPatternById (ErrorPatterns.ErrorPatternId errUuid)
userPermission <- ProjectMembers.getUserPermission pid sess.user.id
let canResolve err = userPermission >= Just ProjectMembers.PEdit || err.assigneeId == Just sess.user.id
case errM of
Nothing -> addErrorToast "Error not found" Nothing >> addRespHeaders mempty
Just err
| err.projectId /= pid -> addErrorToast "Error not found for this project" Nothing >> addRespHeaders mempty
| not (canResolve err) -> do
addErrorToast "You do not have permission to resolve this error" Nothing
addRespHeaders $ errorResolveAction pid err.id err.state False
| otherwise -> do
when (err.state /= ErrorPatterns.ESResolved) do
now <- Time.currentTime
void $ ErrorPatterns.resolveErrorPattern err.id now
issueM <- Issues.selectIssueByHash pid err.hash
whenJust issueM \issue -> Issues.logIssueActivity issue.id Issues.IEResolved (Just sess.user.id) Nothing
addSuccessToast "Error resolved" Nothing
addRespHeaders $ errorResolveAction pid err.id ErrorPatterns.ESResolved True
errorSubscriptionPostH :: Projects.ProjectId -> UUID.UUID -> ErrorSubscriptionForm -> ATAuthCtx (RespHeaders (Html ()))
errorSubscriptionPostH pid errUuid form = do
(_sess, _project) <- Projects.sessionAndProject pid
let errId = ErrorPatterns.ErrorPatternId errUuid
errM <- ErrorPatterns.getErrorPatternById errId
case errM of
Nothing -> addErrorToast "Error not found" Nothing >> addRespHeaders mempty
Just err
| err.projectId /= pid -> addErrorToast "Error not found for this project" Nothing >> addRespHeaders mempty
| otherwise -> do
let notifyEveryRaw = fromMaybe 0 form.notifyEveryMinutes
notifyEvery = clamp (1, 1440) $ if notifyEveryRaw == 0 then 30 else notifyEveryRaw
shouldSubscribe = notifyEveryRaw > 0
now <- Time.currentTime
void $ ErrorPatterns.updateErrorPatternSubscription err.id shouldSubscribe notifyEvery now
addSuccessToast (if shouldSubscribe then "Notifications enabled" else "Notifications disabled") Nothing
addRespHeaders $ errorSubscriptionAction pid err{ErrorPatterns.subscribed = shouldSubscribe, ErrorPatterns.notifyEveryMinutes = notifyEvery}
-- | Form for AI chat input
newtype AIChatForm = AIChatForm {query :: Text}
deriving stock (Generic, Show)
deriving anyclass (FromForm)
-- | System prompt for anomaly investigation AI
anomalySystemPrompt :: UTCTime -> Text
anomalySystemPrompt now =
unlines
[ "You are Monoscope's anomaly-investigation assistant — an expert debugger embedded in the issue detail page. The user is on-call and trying to understand a specific issue. You have access to its details, errors, stack traces, and trace data, plus tools that fetch live telemetry."
, ""
, "Tone: precise, technical, calm. Answer like a senior SRE pairing on a debug — direct, no fluff."
, ""
, "## Current Context"
, "CURRENT TIME (UTC): " <> show now
, "Use the current time to interpret relative phrases (e.g. \"last 2 hours\" → `{\"since\": \"2H\"}`)."
, ""
, "## Telemetry Schema"
, "<schema>"
, Schema.generateSchemaForAI Schema.telemetrySchema
, "</schema>"
, ""
, AI.kqlGuide
, ""
, AI.outputFormatInstructions
, ""
, "## How To Investigate"
, "1. Identify the likely root cause from the error type, stack trace, and surrounding telemetry."
, "2. Use the issue's service / method / path context to narrow down."
, "3. Suggest concrete debugging steps or fixes — name files, fields, queries when possible."
, ""
, "## Tool-Use Policy (overrides the workflow in <output_format>)"
, "- Analysis questions (\"What could cause this?\", \"Suggest a fix\") → answer DIRECTLY from the issue context. Do NOT call tools, and do NOT call `run_query`."
, "- Chart / visualization requests (\"plot errors over time\", \"show a chart of...\") → build the KQL query and `widgets` config directly from <schema>. Do NOT call `get_schema`, `get_field_values`, or `run_query` — the chat panel renders the chart from the query alone."
, "- Call tools ONLY when the answer must contain actual data values from the live store (e.g. \"top 5 services by error count\" where real numbers are required)."
, ""
, "## Response Format"
, "- Lead with a single-sentence summary."
, "- Follow with bullets or short paragraphs — never walls of text."
, "- The chat panel is ~400px wide, so brevity matters."
, "- For chart requests, prioritize a correct KQL query and a data-driven explanation."
]
-- | Handle AI chat POST request
-- Designed to power the AI chat in the anomalies page. The chat thread is loaded via htmx and theres an input which when submitted gets sent here.
aiChatPostH :: Projects.ProjectId -> Issues.IssueId -> AIChatForm -> ATAuthCtx (RespHeaders (Html ()))
aiChatPostH pid issueId form
| T.length form.query > 4000 = addRespHeaders $ aiChatResponse_ pid form.query "Query too long. Maximum 4000 characters allowed." Nothing Nothing Nothing
| otherwise = do
appCtx <- ask @AuthContext
now <- Time.currentTime
let convId = UUIDId issueId.unUUIDId :: UUIDId "conversation"
void $ Issues.getOrCreateConversation pid convId Issues.CTAnomaly (AE.object ["issue_id" AE..= issueId])
issueM <- Issues.selectIssueById issueId
maybe (respond Nothing convId "Issue not found. Unable to analyze." Nothing Nothing True) (processIssue appCtx now convId) issueM
where