@oneuptime/common 11.3.6 → 11.3.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -52,7 +52,7 @@ import ServiceType from "../../Types/Telemetry/ServiceType";
52
52
  import OneUptimeDate from "../../Types/Date";
53
53
  import TelemetryUtil from "../Utils/Telemetry/Telemetry";
54
54
  import logger, { LogAttributes } from "../Utils/Logger";
55
- import NotEqual from "../../Types/BaseDatabase/NotEqual";
55
+ import Semaphore, { SemaphoreMutex } from "../Infrastructure/Semaphore";
56
56
  import IncidentFeedService from "./IncidentFeedService";
57
57
  import IncidentSlaService from "./IncidentSlaService";
58
58
  import { setIsPublicForMarkdownImages } from "../Utils/InlineImageAccessTokenSync";
@@ -2765,359 +2765,463 @@ ${incidentSeverity.name}
2765
2765
  incidentStateTimelines[0];
2766
2766
 
2767
2767
  /*
2768
- * Delete the existing metrics for this incident so the time-varying
2769
- * ones (TimeToAcknowledge / TimeToResolve / IncidentDuration /
2770
- * TimeInState) get rewritten with the latest state-timeline values
2771
- * on this refresh. IncidentCount is excluded from the delete: it is
2772
- * a constant `value = 1` keyed by `primaryEntityId + bucketTime` that
2773
- * never changes. Re-emitting it across refreshes inflated the
2774
- * 1-minute aggregating materialized view (`MetricItemAggMV1m_mv`),
2775
- * because the MV trigger only fires on inserts ALTER DELETE
2776
- * mutations don't roll back the previously-accumulated
2777
- * `sumState` / `countState`. That's why the Incident Dashboard
2778
- * sum-of-IncidentCount widget read ~33% higher than the actual
2779
- * unique-incident count.
2768
+ * Serialize concurrent refreshes for this incident across pods. The
2769
+ * write-once guard below is read-then-insert (findBy existing, then a
2770
+ * conditional createMany). Both call sites fire refresh fire-and-forget
2771
+ * after releasing the per-incident state-timeline mutex, so two
2772
+ * close-together transitions (e.g. auto-acknowledge then auto-resolve)
2773
+ * could interleave: both read a snapshot missing a metric and both
2774
+ * insert it. The Metric table is a plain MergeTree with no row collapse,
2775
+ * so those duplicates would be permanent and re-inflate the very Sum/Avg
2776
+ * widgets this change fixes. A best-effort distributed lock keyed on the
2777
+ * incident makes the read+insert atomic; if the lock can't be taken we
2778
+ * still proceed (the existence check alone covers the common sequential
2779
+ * case).
2780
2780
  */
2781
- await MetricService.deleteBy({
2782
- query: {
2783
- projectId: incident.projectId,
2784
- primaryEntityId: data.incidentId,
2785
- name: new NotEqual<string>(IncidentMetricType.IncidentCount),
2786
- },
2787
- props: {
2788
- isRoot: true,
2789
- },
2790
- });
2791
-
2792
- const itemsToSave: Array<Metric> = [];
2793
-
2794
- const metricRetentionDays: number = await this.getMetricRetentionDays();
2795
- const incidentMetricRetentionDate: Date = OneUptimeDate.addRemoveDays(
2796
- OneUptimeDate.getCurrentDate(),
2797
- metricRetentionDays,
2798
- );
2781
+ let metricRefreshMutex: SemaphoreMutex | null = null;
2782
+ try {
2783
+ metricRefreshMutex = await Semaphore.lock({
2784
+ key: data.incidentId.toString(),
2785
+ namespace: "IncidentService.refreshIncidentMetrics",
2786
+ lockTimeout: 30000,
2787
+ });
2788
+ } catch (err) {
2789
+ logger.error(
2790
+ err as Error,
2791
+ {
2792
+ projectId: incident.projectId?.toString(),
2793
+ incidentId: incident.id?.toString(),
2794
+ } as LogAttributes,
2795
+ );
2796
+ }
2799
2797
 
2800
- // now we need to create new metrics for this incident - TimeToAcknowledge, TimeToResolve, IncidentCount, IncidentDuration
2798
+ try {
2799
+ /*
2800
+ * Write-once metrics — no deletes.
2801
+ *
2802
+ * The raw Metric table (`MetricItemV3`) feeds an AggregatingMergeTree
2803
+ * materialized view (`MetricItemAggMV1m_mv`) that accumulates
2804
+ * `sumState`/`countState` per (projectId, name, primaryEntityId,
2805
+ * minute). The MV trigger fires only on INSERT — an `ALTER ... DELETE`
2806
+ * mutation on the source rolls back nothing in the MV. So the old
2807
+ * "delete every metric for this incident, then re-insert" refresh both
2808
+ * (a) inflated those rollups (the Sum-of-IncidentCount widget read high)
2809
+ * and (b) issued one heavy `ALTER ... DELETE` mutation per state
2810
+ * transition per incident — a mutation storm.
2811
+ *
2812
+ * Every incident metric has a single eventually-final value:
2813
+ * IncidentCount is constant `value = 1`; MTTA/MTTR/duration/time-in-state
2814
+ * are each fixed once the relevant state transition has happened. So we
2815
+ * load what we've already emitted for this incident and insert each
2816
+ * metric exactly once, when it becomes final. Repeat refreshes that find
2817
+ * nothing new are pure no-ops (no insert, no mutation).
2818
+ */
2819
+ const existingMetrics: Array<Metric> = await MetricService.findBy({
2820
+ query: {
2821
+ projectId: incident.projectId,
2822
+ primaryEntityId: data.incidentId,
2823
+ },
2824
+ select: {
2825
+ name: true,
2826
+ time: true,
2827
+ attributes: true,
2828
+ },
2829
+ skip: 0,
2830
+ limit: LIMIT_PER_PROJECT,
2831
+ props: {
2832
+ isRoot: true,
2833
+ },
2834
+ });
2801
2835
 
2802
- const incidentStartsAt: Date =
2803
- firstIncidentStateTimeline?.startsAt ||
2804
- incident.declaredAt ||
2805
- incident.createdAt ||
2806
- OneUptimeDate.getCurrentDate();
2836
+ const existingMetricNames: Set<string> = new Set<string>(
2837
+ existingMetrics
2838
+ .map((metric: Metric) => {
2839
+ return metric.name;
2840
+ })
2841
+ .filter(Boolean) as Array<string>,
2842
+ );
2807
2843
 
2808
- const metricTypesMap: Dictionary<MetricType> = {};
2844
+ /*
2845
+ * Identity of a TimeInState row: the state plus the instant that state
2846
+ * began (a state can be entered more than once over a re-opened
2847
+ * incident). Second granularity is robust against DateTime64 round-trip
2848
+ * jitter and two distinct entries into the same state within one second
2849
+ * never happen.
2850
+ */
2851
+ const buildTimeInStateKey: (
2852
+ stateId: string | undefined,
2853
+ startsAt: Date | undefined,
2854
+ ) => string = (
2855
+ stateId: string | undefined,
2856
+ startsAt: Date | undefined,
2857
+ ): string => {
2858
+ return `${stateId || ""}|${
2859
+ startsAt ? OneUptimeDate.toUnixTimestamp(startsAt) : ""
2860
+ }`;
2861
+ };
2809
2862
 
2810
- /*
2811
- * common attributes shared by all incident metrics
2812
- * All values must be strings for ClickHouse Map(String, String) storage.
2813
- * Arrays are joined as comma-separated strings.
2814
- */
2815
- const baseMetricAttributes: JSONObject = {
2816
- incidentId: data.incidentId.toString(),
2817
- projectId: incident.projectId.toString(),
2818
- monitorIds: (
2819
- incident.monitors
2820
- ?.map((monitor: Monitor) => {
2821
- return monitor._id?.toString();
2822
- })
2823
- .filter(Boolean) || []
2824
- ).join(", "),
2825
- monitorNames: (
2826
- incident.monitors
2827
- ?.map((monitor: Monitor) => {
2828
- return monitor.name?.toString();
2863
+ const existingTimeInStateKeys: Set<string> = new Set<string>(
2864
+ existingMetrics
2865
+ .filter((metric: Metric) => {
2866
+ return metric.name === IncidentMetricType.TimeInState;
2829
2867
  })
2830
- .filter(Boolean) || []
2831
- ).join(", "),
2832
- incidentSeverityId: incident.incidentSeverity?._id?.toString(),
2833
- incidentSeverityName: incident.incidentSeverity?.name?.toString(),
2834
- ownerUserIds: ownerUserIds.join(", "),
2835
- ownerUserNames: ownerUserNames.join(", "),
2836
- ownerTeamIds: ownerTeamIds.join(", "),
2837
- ownerTeamNames: ownerTeamNames.join(", "),
2838
- };
2868
+ .map((metric: Metric) => {
2869
+ return buildTimeInStateKey(
2870
+ metric.attributes?.["incidentStateId"] as string | undefined,
2871
+ metric.time,
2872
+ );
2873
+ }),
2874
+ );
2839
2875
 
2840
- /*
2841
- * Only emit IncidentCount on the very first refresh (i.e. when no
2842
- * existing IncidentCount row is present for this primaryEntityId). See
2843
- * the delete comment above — emitting it on every refresh would
2844
- * accumulate phantom `sumState` entries in the MV that ALTER
2845
- * DELETE can't undo. By keeping the original row alive and never
2846
- * re-emitting, the dashboard Sum stays equal to the true count of
2847
- * distinct incidents.
2848
- */
2849
- const incidentCountMetricExists: boolean = await MetricService.existsBy({
2850
- query: {
2851
- projectId: incident.projectId,
2852
- primaryEntityId: data.incidentId,
2853
- name: IncidentMetricType.IncidentCount,
2854
- },
2855
- props: {
2856
- isRoot: true,
2857
- },
2858
- });
2876
+ const itemsToSave: Array<Metric> = [];
2859
2877
 
2860
- if (!incidentCountMetricExists) {
2861
- const incidentCountMetric: Metric = new Metric();
2862
-
2863
- incidentCountMetric.projectId = incident.projectId;
2864
- incidentCountMetric.primaryEntityId = incident.id!;
2865
- incidentCountMetric.primaryEntityType = ServiceType.Incident;
2866
- incidentCountMetric.name = IncidentMetricType.IncidentCount;
2867
- incidentCountMetric.value = 1;
2868
- incidentCountMetric.attributes = { ...baseMetricAttributes };
2869
- incidentCountMetric.attributeKeys = TelemetryUtil.getAttributeKeys(
2870
- incidentCountMetric.attributes,
2878
+ const metricRetentionDays: number = await this.getMetricRetentionDays();
2879
+ const incidentMetricRetentionDate: Date = OneUptimeDate.addRemoveDays(
2880
+ OneUptimeDate.getCurrentDate(),
2881
+ metricRetentionDays,
2871
2882
  );
2872
2883
 
2873
- incidentCountMetric.time = incidentStartsAt;
2874
- incidentCountMetric.timeUnixNano = OneUptimeDate.toUnixNano(
2875
- incidentCountMetric.time,
2876
- );
2877
- incidentCountMetric.metricPointType = MetricPointType.Sum;
2878
- incidentCountMetric.retentionDate = incidentMetricRetentionDate;
2884
+ // now we need to create new metrics for this incident - TimeToAcknowledge, TimeToResolve, IncidentCount, IncidentDuration
2879
2885
 
2880
- itemsToSave.push(incidentCountMetric);
2881
- }
2886
+ const incidentStartsAt: Date =
2887
+ firstIncidentStateTimeline?.startsAt ||
2888
+ incident.declaredAt ||
2889
+ incident.createdAt ||
2890
+ OneUptimeDate.getCurrentDate();
2882
2891
 
2883
- // Always register the metric type so it shows up in the type catalog.
2884
- const metricType: MetricType = new MetricType();
2885
- metricType.name = IncidentMetricType.IncidentCount;
2886
- metricType.description = "Number of incidents created";
2887
- metricType.unit = "";
2888
- metricType.services = [];
2892
+ const metricTypesMap: Dictionary<MetricType> = {};
2889
2893
 
2890
- metricTypesMap[IncidentMetricType.IncidentCount] = metricType;
2894
+ /*
2895
+ * common attributes shared by all incident metrics
2896
+ * All values must be strings for ClickHouse Map(String, String) storage.
2897
+ * Arrays are joined as comma-separated strings.
2898
+ */
2899
+ const baseMetricAttributes: JSONObject = {
2900
+ incidentId: data.incidentId.toString(),
2901
+ projectId: incident.projectId.toString(),
2902
+ monitorIds: (
2903
+ incident.monitors
2904
+ ?.map((monitor: Monitor) => {
2905
+ return monitor._id?.toString();
2906
+ })
2907
+ .filter(Boolean) || []
2908
+ ).join(", "),
2909
+ monitorNames: (
2910
+ incident.monitors
2911
+ ?.map((monitor: Monitor) => {
2912
+ return monitor.name?.toString();
2913
+ })
2914
+ .filter(Boolean) || []
2915
+ ).join(", "),
2916
+ incidentSeverityId: incident.incidentSeverity?._id?.toString(),
2917
+ incidentSeverityName: incident.incidentSeverity?.name?.toString(),
2918
+ ownerUserIds: ownerUserIds.join(", "),
2919
+ ownerUserNames: ownerUserNames.join(", "),
2920
+ ownerTeamIds: ownerTeamIds.join(", "),
2921
+ ownerTeamNames: ownerTeamNames.join(", "),
2922
+ };
2891
2923
 
2892
- // is the incident acknowledged?
2893
- const isIncidentAcknowledged: boolean = incidentStateTimelines.some(
2894
- (timeline: IncidentStateTimeline) => {
2895
- return timeline.incidentState?.isAcknowledgedState;
2896
- },
2897
- );
2924
+ /*
2925
+ * Only emit IncidentCount on the very first refresh (i.e. when no
2926
+ * existing IncidentCount row is present for this primaryEntityId). See
2927
+ * the write-once note above — emitting it on every refresh would
2928
+ * accumulate phantom `sumState` entries in the MV. By keeping the
2929
+ * original row alive and never re-emitting, the dashboard Sum stays
2930
+ * equal to the true count of distinct incidents.
2931
+ */
2932
+ if (!existingMetricNames.has(IncidentMetricType.IncidentCount)) {
2933
+ const incidentCountMetric: Metric = new Metric();
2934
+
2935
+ incidentCountMetric.projectId = incident.projectId;
2936
+ incidentCountMetric.primaryEntityId = incident.id!;
2937
+ incidentCountMetric.primaryEntityType = ServiceType.Incident;
2938
+ incidentCountMetric.name = IncidentMetricType.IncidentCount;
2939
+ incidentCountMetric.value = 1;
2940
+ incidentCountMetric.attributes = { ...baseMetricAttributes };
2941
+ incidentCountMetric.attributeKeys = TelemetryUtil.getAttributeKeys(
2942
+ incidentCountMetric.attributes,
2943
+ );
2898
2944
 
2899
- if (isIncidentAcknowledged) {
2900
- const ackIncidentStateTimeline: IncidentStateTimeline | undefined =
2901
- incidentStateTimelines.find((timeline: IncidentStateTimeline) => {
2902
- return timeline.incidentState?.isAcknowledgedState;
2903
- });
2945
+ incidentCountMetric.time = incidentStartsAt;
2946
+ incidentCountMetric.timeUnixNano = OneUptimeDate.toUnixNano(
2947
+ incidentCountMetric.time,
2948
+ );
2949
+ incidentCountMetric.metricPointType = MetricPointType.Sum;
2950
+ incidentCountMetric.retentionDate = incidentMetricRetentionDate;
2904
2951
 
2905
- if (ackIncidentStateTimeline) {
2906
- const timeToAcknowledgeMetric: Metric = new Metric();
2952
+ itemsToSave.push(incidentCountMetric);
2953
+ }
2907
2954
 
2908
- timeToAcknowledgeMetric.projectId = incident.projectId;
2909
- timeToAcknowledgeMetric.primaryEntityId = incident.id!;
2910
- timeToAcknowledgeMetric.primaryEntityType = ServiceType.Incident;
2911
- timeToAcknowledgeMetric.name = IncidentMetricType.TimeToAcknowledge;
2912
- timeToAcknowledgeMetric.value = OneUptimeDate.getDifferenceInSeconds(
2913
- ackIncidentStateTimeline?.startsAt || OneUptimeDate.getCurrentDate(),
2914
- incidentStartsAt,
2915
- );
2916
- timeToAcknowledgeMetric.attributes = { ...baseMetricAttributes };
2917
- timeToAcknowledgeMetric.attributeKeys = TelemetryUtil.getAttributeKeys(
2918
- timeToAcknowledgeMetric.attributes,
2919
- );
2955
+ // Always register the metric type so it shows up in the type catalog.
2956
+ const metricType: MetricType = new MetricType();
2957
+ metricType.name = IncidentMetricType.IncidentCount;
2958
+ metricType.description = "Number of incidents created";
2959
+ metricType.unit = "";
2960
+ metricType.services = [];
2920
2961
 
2921
- timeToAcknowledgeMetric.time =
2922
- ackIncidentStateTimeline?.startsAt ||
2923
- incident.declaredAt ||
2924
- incident.createdAt ||
2925
- OneUptimeDate.getCurrentDate();
2926
- timeToAcknowledgeMetric.timeUnixNano = OneUptimeDate.toUnixNano(
2927
- timeToAcknowledgeMetric.time,
2928
- );
2929
- timeToAcknowledgeMetric.metricPointType = MetricPointType.Sum;
2930
- timeToAcknowledgeMetric.retentionDate = incidentMetricRetentionDate;
2962
+ metricTypesMap[IncidentMetricType.IncidentCount] = metricType;
2931
2963
 
2932
- itemsToSave.push(timeToAcknowledgeMetric);
2964
+ // is the incident acknowledged?
2965
+ const isIncidentAcknowledged: boolean = incidentStateTimelines.some(
2966
+ (timeline: IncidentStateTimeline) => {
2967
+ return timeline.incidentState?.isAcknowledgedState;
2968
+ },
2969
+ );
2933
2970
 
2934
- // add metric type for this to map.
2935
- const metricType: MetricType = new MetricType();
2936
- metricType.name = timeToAcknowledgeMetric.name;
2937
- metricType.description = "Time taken to acknowledge the incident";
2938
- metricType.unit = "seconds";
2971
+ if (isIncidentAcknowledged) {
2972
+ const ackIncidentStateTimeline: IncidentStateTimeline | undefined =
2973
+ incidentStateTimelines.find((timeline: IncidentStateTimeline) => {
2974
+ return timeline.incidentState?.isAcknowledgedState;
2975
+ });
2939
2976
 
2940
- // add to map.
2941
- metricTypesMap[timeToAcknowledgeMetric.name] = metricType;
2977
+ if (ackIncidentStateTimeline) {
2978
+ // register the metric type so the catalog stays complete across refreshes.
2979
+ const metricType: MetricType = new MetricType();
2980
+ metricType.name = IncidentMetricType.TimeToAcknowledge;
2981
+ metricType.description = "Time taken to acknowledge the incident";
2982
+ metricType.unit = "seconds";
2983
+ metricTypesMap[IncidentMetricType.TimeToAcknowledge] = metricType;
2984
+
2985
+ // write-once: MTTA is fixed once the incident is first acknowledged.
2986
+ if (!existingMetricNames.has(IncidentMetricType.TimeToAcknowledge)) {
2987
+ const timeToAcknowledgeMetric: Metric = new Metric();
2988
+
2989
+ timeToAcknowledgeMetric.projectId = incident.projectId;
2990
+ timeToAcknowledgeMetric.primaryEntityId = incident.id!;
2991
+ timeToAcknowledgeMetric.primaryEntityType = ServiceType.Incident;
2992
+ timeToAcknowledgeMetric.name = IncidentMetricType.TimeToAcknowledge;
2993
+ timeToAcknowledgeMetric.value =
2994
+ OneUptimeDate.getDifferenceInSeconds(
2995
+ ackIncidentStateTimeline?.startsAt ||
2996
+ OneUptimeDate.getCurrentDate(),
2997
+ incidentStartsAt,
2998
+ );
2999
+ timeToAcknowledgeMetric.attributes = { ...baseMetricAttributes };
3000
+ timeToAcknowledgeMetric.attributeKeys =
3001
+ TelemetryUtil.getAttributeKeys(
3002
+ timeToAcknowledgeMetric.attributes,
3003
+ );
3004
+
3005
+ timeToAcknowledgeMetric.time =
3006
+ ackIncidentStateTimeline?.startsAt ||
3007
+ incident.declaredAt ||
3008
+ incident.createdAt ||
3009
+ OneUptimeDate.getCurrentDate();
3010
+ timeToAcknowledgeMetric.timeUnixNano = OneUptimeDate.toUnixNano(
3011
+ timeToAcknowledgeMetric.time,
3012
+ );
3013
+ timeToAcknowledgeMetric.metricPointType = MetricPointType.Sum;
3014
+ timeToAcknowledgeMetric.retentionDate = incidentMetricRetentionDate;
3015
+
3016
+ itemsToSave.push(timeToAcknowledgeMetric);
3017
+ }
3018
+ }
2942
3019
  }
2943
- }
2944
3020
 
2945
- // time to resolve
2946
- const isIncidentResolved: boolean = incidentStateTimelines.some(
2947
- (timeline: IncidentStateTimeline) => {
2948
- return timeline.incidentState?.isResolvedState;
2949
- },
2950
- );
3021
+ // time to resolve
3022
+ const isIncidentResolved: boolean = incidentStateTimelines.some(
3023
+ (timeline: IncidentStateTimeline) => {
3024
+ return timeline.incidentState?.isResolvedState;
3025
+ },
3026
+ );
2951
3027
 
2952
- if (isIncidentResolved) {
2953
3028
  const resolvedIncidentStateTimeline: IncidentStateTimeline | undefined =
2954
3029
  incidentStateTimelines.find((timeline: IncidentStateTimeline) => {
2955
3030
  return timeline.incidentState?.isResolvedState;
2956
3031
  });
2957
3032
 
2958
- if (resolvedIncidentStateTimeline) {
2959
- const timeToResolveMetric: Metric = new Metric();
2960
-
2961
- timeToResolveMetric.projectId = incident.projectId;
2962
- timeToResolveMetric.primaryEntityId = incident.id!;
2963
- timeToResolveMetric.primaryEntityType = ServiceType.Incident;
2964
- timeToResolveMetric.name = IncidentMetricType.TimeToResolve;
2965
- timeToResolveMetric.value = OneUptimeDate.getDifferenceInSeconds(
2966
- resolvedIncidentStateTimeline?.startsAt ||
2967
- OneUptimeDate.getCurrentDate(),
2968
- incidentStartsAt,
2969
- );
2970
- timeToResolveMetric.attributes = { ...baseMetricAttributes };
2971
- timeToResolveMetric.attributeKeys = TelemetryUtil.getAttributeKeys(
2972
- timeToResolveMetric.attributes,
2973
- );
2974
-
2975
- timeToResolveMetric.time =
2976
- resolvedIncidentStateTimeline?.startsAt ||
2977
- incident.declaredAt ||
2978
- incident.createdAt ||
2979
- OneUptimeDate.getCurrentDate();
2980
- timeToResolveMetric.timeUnixNano = OneUptimeDate.toUnixNano(
2981
- timeToResolveMetric.time,
2982
- );
2983
- timeToResolveMetric.metricPointType = MetricPointType.Sum;
2984
- timeToResolveMetric.retentionDate = incidentMetricRetentionDate;
2985
-
2986
- itemsToSave.push(timeToResolveMetric);
2987
-
2988
- // add metric type for this to map.
3033
+ if (isIncidentResolved && resolvedIncidentStateTimeline) {
3034
+ // register the metric type so the catalog stays complete across refreshes.
2989
3035
  const metricType: MetricType = new MetricType();
2990
- metricType.name = timeToResolveMetric.name;
3036
+ metricType.name = IncidentMetricType.TimeToResolve;
2991
3037
  metricType.description = "Time taken to resolve the incident";
2992
3038
  metricType.unit = "seconds";
2993
- // add to map.
2994
- metricTypesMap[timeToResolveMetric.name] = metricType;
2995
- }
2996
- }
2997
-
2998
- // incident duration
2999
-
3000
- const incidentDurationMetric: Metric = new Metric();
3039
+ metricTypesMap[IncidentMetricType.TimeToResolve] = metricType;
3040
+
3041
+ // write-once: MTTR is fixed once the incident is first resolved.
3042
+ if (!existingMetricNames.has(IncidentMetricType.TimeToResolve)) {
3043
+ const timeToResolveMetric: Metric = new Metric();
3044
+
3045
+ timeToResolveMetric.projectId = incident.projectId;
3046
+ timeToResolveMetric.primaryEntityId = incident.id!;
3047
+ timeToResolveMetric.primaryEntityType = ServiceType.Incident;
3048
+ timeToResolveMetric.name = IncidentMetricType.TimeToResolve;
3049
+ timeToResolveMetric.value = OneUptimeDate.getDifferenceInSeconds(
3050
+ resolvedIncidentStateTimeline?.startsAt ||
3051
+ OneUptimeDate.getCurrentDate(),
3052
+ incidentStartsAt,
3053
+ );
3054
+ timeToResolveMetric.attributes = { ...baseMetricAttributes };
3055
+ timeToResolveMetric.attributeKeys = TelemetryUtil.getAttributeKeys(
3056
+ timeToResolveMetric.attributes,
3057
+ );
3001
3058
 
3002
- const lastIncidentStateTimeline: IncidentStateTimeline | undefined =
3003
- incidentStateTimelines[incidentStateTimelines.length - 1];
3059
+ timeToResolveMetric.time =
3060
+ resolvedIncidentStateTimeline?.startsAt ||
3061
+ incident.declaredAt ||
3062
+ incident.createdAt ||
3063
+ OneUptimeDate.getCurrentDate();
3064
+ timeToResolveMetric.timeUnixNano = OneUptimeDate.toUnixNano(
3065
+ timeToResolveMetric.time,
3066
+ );
3067
+ timeToResolveMetric.metricPointType = MetricPointType.Sum;
3068
+ timeToResolveMetric.retentionDate = incidentMetricRetentionDate;
3004
3069
 
3005
- if (lastIncidentStateTimeline) {
3006
- const incidentEndsAt: Date =
3007
- lastIncidentStateTimeline.startsAt || OneUptimeDate.getCurrentDate();
3070
+ itemsToSave.push(timeToResolveMetric);
3071
+ }
3072
+ }
3008
3073
 
3009
- // save metric.
3074
+ /*
3075
+ * Incident duration — write-once, finalized at resolution.
3076
+ *
3077
+ * The previous implementation recomputed duration as
3078
+ * (latest state transition − start) on every refresh, so an open
3079
+ * incident's duration grew and had to be deleted + re-inserted each
3080
+ * time. With no deletes we emit a single final duration once the
3081
+ * incident reaches a resolved state: (first resolution − start). An
3082
+ * incident that is never resolved has no duration row (its lifetime is
3083
+ * not yet final), which keeps the "Avg Duration" widget meaningful.
3084
+ */
3085
+ if (isIncidentResolved && resolvedIncidentStateTimeline) {
3086
+ // register the metric type so the catalog stays complete across refreshes.
3087
+ const metricType: MetricType = new MetricType();
3088
+ metricType.name = IncidentMetricType.IncidentDuration;
3089
+ metricType.description = "Duration of the incident";
3090
+ metricType.unit = "seconds";
3091
+ metricTypesMap[IncidentMetricType.IncidentDuration] = metricType;
3092
+
3093
+ if (!existingMetricNames.has(IncidentMetricType.IncidentDuration)) {
3094
+ const incidentEndsAt: Date =
3095
+ resolvedIncidentStateTimeline.startsAt ||
3096
+ OneUptimeDate.getCurrentDate();
3097
+
3098
+ const incidentDurationMetric: Metric = new Metric();
3099
+
3100
+ incidentDurationMetric.projectId = incident.projectId;
3101
+ incidentDurationMetric.primaryEntityId = incident.id!;
3102
+ incidentDurationMetric.primaryEntityType = ServiceType.Incident;
3103
+ incidentDurationMetric.name = IncidentMetricType.IncidentDuration;
3104
+ incidentDurationMetric.value = OneUptimeDate.getDifferenceInSeconds(
3105
+ incidentEndsAt,
3106
+ incidentStartsAt,
3107
+ );
3108
+ incidentDurationMetric.attributes = { ...baseMetricAttributes };
3109
+ incidentDurationMetric.attributeKeys = TelemetryUtil.getAttributeKeys(
3110
+ incidentDurationMetric.attributes,
3111
+ );
3010
3112
 
3011
- incidentDurationMetric.projectId = incident.projectId;
3012
- incidentDurationMetric.primaryEntityId = incident.id!;
3013
- incidentDurationMetric.primaryEntityType = ServiceType.Incident;
3014
- incidentDurationMetric.name = IncidentMetricType.IncidentDuration;
3015
- incidentDurationMetric.value = OneUptimeDate.getDifferenceInSeconds(
3016
- incidentEndsAt,
3017
- incidentStartsAt,
3018
- );
3019
- incidentDurationMetric.attributes = { ...baseMetricAttributes };
3020
- incidentDurationMetric.attributeKeys = TelemetryUtil.getAttributeKeys(
3021
- incidentDurationMetric.attributes,
3022
- );
3113
+ incidentDurationMetric.time = incidentEndsAt;
3114
+ incidentDurationMetric.timeUnixNano = OneUptimeDate.toUnixNano(
3115
+ incidentDurationMetric.time,
3116
+ );
3117
+ incidentDurationMetric.metricPointType = MetricPointType.Sum;
3118
+ incidentDurationMetric.retentionDate = incidentMetricRetentionDate;
3023
3119
 
3024
- incidentDurationMetric.time =
3025
- lastIncidentStateTimeline?.startsAt ||
3026
- incident.declaredAt ||
3027
- incident.createdAt ||
3028
- OneUptimeDate.getCurrentDate();
3029
- incidentDurationMetric.timeUnixNano = OneUptimeDate.toUnixNano(
3030
- incidentDurationMetric.time,
3031
- );
3032
- incidentDurationMetric.metricPointType = MetricPointType.Sum;
3033
- incidentDurationMetric.retentionDate = incidentMetricRetentionDate;
3120
+ itemsToSave.push(incidentDurationMetric);
3121
+ }
3122
+ }
3034
3123
 
3035
- itemsToSave.push(incidentDurationMetric);
3124
+ // time-in-state metrics — emit one metric per state transition that has a completed duration
3125
+ for (const timeline of incidentStateTimelines) {
3126
+ if (!timeline.startsAt || !timeline.endsAt) {
3127
+ continue;
3128
+ }
3036
3129
 
3037
- // add metric type for this to map.
3038
- const metricType: MetricType = new MetricType();
3039
- metricType.name = incidentDurationMetric.name;
3040
- metricType.description = "Duration of the incident";
3041
- metricType.unit = "seconds";
3130
+ // write-once: each completed state transition is emitted exactly once.
3131
+ const timeInStateKey: string = buildTimeInStateKey(
3132
+ timeline.incidentStateId?.toString(),
3133
+ timeline.startsAt,
3134
+ );
3135
+ if (existingTimeInStateKeys.has(timeInStateKey)) {
3136
+ continue;
3137
+ }
3042
3138
 
3043
- // add to map.
3044
- metricTypesMap[incidentDurationMetric.name] = metricType;
3045
- }
3139
+ const stateName: string =
3140
+ timeline.incidentState?.name?.toString() || "Unknown";
3046
3141
 
3047
- // time-in-state metrics emit one metric per state transition that has a completed duration
3048
- for (const timeline of incidentStateTimelines) {
3049
- if (!timeline.startsAt || !timeline.endsAt) {
3050
- continue;
3051
- }
3142
+ const timeInStateMetric: Metric = new Metric();
3052
3143
 
3053
- const stateName: string =
3054
- timeline.incidentState?.name?.toString() || "Unknown";
3144
+ timeInStateMetric.projectId = incident.projectId;
3145
+ timeInStateMetric.primaryEntityId = incident.id!;
3146
+ timeInStateMetric.primaryEntityType = ServiceType.Incident;
3147
+ timeInStateMetric.name = IncidentMetricType.TimeInState;
3148
+ timeInStateMetric.value = OneUptimeDate.getDifferenceInSeconds(
3149
+ timeline.endsAt,
3150
+ timeline.startsAt,
3151
+ );
3152
+ timeInStateMetric.attributes = {
3153
+ ...baseMetricAttributes,
3154
+ incidentStateName: stateName,
3155
+ incidentStateId: timeline.incidentStateId?.toString(),
3156
+ isCreatedState:
3157
+ timeline.incidentState?.isCreatedState?.toString() || "false",
3158
+ isAcknowledgedState:
3159
+ timeline.incidentState?.isAcknowledgedState?.toString() || "false",
3160
+ isResolvedState:
3161
+ timeline.incidentState?.isResolvedState?.toString() || "false",
3162
+ };
3163
+ timeInStateMetric.attributeKeys = TelemetryUtil.getAttributeKeys(
3164
+ timeInStateMetric.attributes,
3165
+ );
3055
3166
 
3056
- const timeInStateMetric: Metric = new Metric();
3167
+ timeInStateMetric.time = timeline.startsAt;
3168
+ timeInStateMetric.timeUnixNano = OneUptimeDate.toUnixNano(
3169
+ timeInStateMetric.time,
3170
+ );
3171
+ timeInStateMetric.metricPointType = MetricPointType.Sum;
3172
+ timeInStateMetric.retentionDate = incidentMetricRetentionDate;
3057
3173
 
3058
- timeInStateMetric.projectId = incident.projectId;
3059
- timeInStateMetric.primaryEntityId = incident.id!;
3060
- timeInStateMetric.primaryEntityType = ServiceType.Incident;
3061
- timeInStateMetric.name = IncidentMetricType.TimeInState;
3062
- timeInStateMetric.value = OneUptimeDate.getDifferenceInSeconds(
3063
- timeline.endsAt,
3064
- timeline.startsAt,
3065
- );
3066
- timeInStateMetric.attributes = {
3067
- ...baseMetricAttributes,
3068
- incidentStateName: stateName,
3069
- incidentStateId: timeline.incidentStateId?.toString(),
3070
- isCreatedState:
3071
- timeline.incidentState?.isCreatedState?.toString() || "false",
3072
- isAcknowledgedState:
3073
- timeline.incidentState?.isAcknowledgedState?.toString() || "false",
3074
- isResolvedState:
3075
- timeline.incidentState?.isResolvedState?.toString() || "false",
3076
- };
3077
- timeInStateMetric.attributeKeys = TelemetryUtil.getAttributeKeys(
3078
- timeInStateMetric.attributes,
3079
- );
3174
+ itemsToSave.push(timeInStateMetric);
3175
+ }
3080
3176
 
3081
- timeInStateMetric.time = timeline.startsAt;
3082
- timeInStateMetric.timeUnixNano = OneUptimeDate.toUnixNano(
3083
- timeInStateMetric.time,
3084
- );
3085
- timeInStateMetric.metricPointType = MetricPointType.Sum;
3086
- timeInStateMetric.retentionDate = incidentMetricRetentionDate;
3177
+ // add metric type for time-in-state to map (only once)
3178
+ if (
3179
+ incidentStateTimelines.some((t: IncidentStateTimeline) => {
3180
+ return t.startsAt && t.endsAt;
3181
+ })
3182
+ ) {
3183
+ const timeInStateMetricType: MetricType = new MetricType();
3184
+ timeInStateMetricType.name = IncidentMetricType.TimeInState;
3185
+ timeInStateMetricType.description =
3186
+ "Time spent in each incident state (e.g. Created, Investigating, Acknowledged)";
3187
+ timeInStateMetricType.unit = "seconds";
3188
+ metricTypesMap[timeInStateMetricType.name] = timeInStateMetricType;
3189
+ }
3087
3190
 
3088
- itemsToSave.push(timeInStateMetric);
3089
- }
3191
+ // write-once: a refresh that finds nothing new inserts nothing.
3192
+ if (itemsToSave.length > 0) {
3193
+ await MetricService.createMany({
3194
+ items: itemsToSave,
3195
+ props: {
3196
+ isRoot: true,
3197
+ },
3198
+ });
3199
+ }
3090
3200
 
3091
- // add metric type for time-in-state to map (only once)
3092
- if (
3093
- incidentStateTimelines.some((t: IncidentStateTimeline) => {
3094
- return t.startsAt && t.endsAt;
3095
- })
3096
- ) {
3097
- const timeInStateMetricType: MetricType = new MetricType();
3098
- timeInStateMetricType.name = IncidentMetricType.TimeInState;
3099
- timeInStateMetricType.description =
3100
- "Time spent in each incident state (e.g. Created, Investigating, Acknowledged)";
3101
- timeInStateMetricType.unit = "seconds";
3102
- metricTypesMap[timeInStateMetricType.name] = timeInStateMetricType;
3201
+ TelemetryUtil.indexMetricNameServiceNameMap({
3202
+ metricNameServiceNameMap: metricTypesMap,
3203
+ projectId: incident.projectId,
3204
+ }).catch((err: Error) => {
3205
+ logger.error(err, {
3206
+ projectId: incident.projectId?.toString(),
3207
+ incidentId: incident.id?.toString(),
3208
+ } as LogAttributes);
3209
+ });
3210
+ } finally {
3211
+ if (metricRefreshMutex) {
3212
+ try {
3213
+ await Semaphore.release(metricRefreshMutex);
3214
+ } catch (err) {
3215
+ logger.error(
3216
+ err as Error,
3217
+ {
3218
+ projectId: incident.projectId?.toString(),
3219
+ incidentId: incident.id?.toString(),
3220
+ } as LogAttributes,
3221
+ );
3222
+ }
3223
+ }
3103
3224
  }
3104
-
3105
- await MetricService.createMany({
3106
- items: itemsToSave,
3107
- props: {
3108
- isRoot: true,
3109
- },
3110
- });
3111
-
3112
- TelemetryUtil.indexMetricNameServiceNameMap({
3113
- metricNameServiceNameMap: metricTypesMap,
3114
- projectId: incident.projectId,
3115
- }).catch((err: Error) => {
3116
- logger.error(err, {
3117
- projectId: incident.projectId?.toString(),
3118
- incidentId: incident.id?.toString(),
3119
- } as LogAttributes);
3120
- });
3121
3225
  }
3122
3226
 
3123
3227
  @CaptureSpan()