@oneuptime/common 10.5.1 → 10.5.2

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.
Files changed (56) hide show
  1. package/Models/DatabaseModels/TelemetryException.ts +10 -0
  2. package/Server/API/TelemetryAPI.ts +406 -0
  3. package/Server/Infrastructure/Postgres/SchemaMigrations/1779879993421-MigrationName.ts +20 -0
  4. package/Server/Infrastructure/Postgres/SchemaMigrations/1779900000000-DedupeTelemetryExceptionsAndAddUniqueIndex.ts +115 -0
  5. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +4 -0
  6. package/Server/Services/ExceptionAggregationService.ts +51 -3
  7. package/Server/Services/LogAggregationService.ts +1 -0
  8. package/Server/Services/MetricAggregationService.ts +227 -0
  9. package/Server/Services/OpenTelemetryIngestService.ts +101 -1
  10. package/Server/Services/TraceAggregationService.ts +1 -0
  11. package/Server/Utils/Monitor/MonitorLogUtil.ts +146 -6
  12. package/Server/Utils/Telemetry/ResourceFacetResolver.ts +299 -0
  13. package/UI/Components/LogsViewer/LogsViewer.tsx +10 -0
  14. package/UI/Components/LogsViewer/components/FacetSection.tsx +40 -3
  15. package/UI/Components/LogsViewer/components/LogsFacetSidebar.tsx +23 -0
  16. package/UI/Components/LogsViewer/types.ts +2 -0
  17. package/UI/Components/TelemetryViewer/TelemetryViewer.tsx +8 -0
  18. package/UI/Components/TelemetryViewer/components/TelemetryFacetSection.tsx +49 -3
  19. package/UI/Components/TelemetryViewer/components/TelemetryFacetSidebar.tsx +16 -0
  20. package/UI/Components/TelemetryViewer/types.ts +12 -0
  21. package/build/dist/Models/DatabaseModels/TelemetryException.js +11 -0
  22. package/build/dist/Models/DatabaseModels/TelemetryException.js.map +1 -1
  23. package/build/dist/Server/API/TelemetryAPI.js +285 -0
  24. package/build/dist/Server/API/TelemetryAPI.js.map +1 -1
  25. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1779879993421-MigrationName.js +18 -0
  26. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1779879993421-MigrationName.js.map +1 -0
  27. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1779900000000-DedupeTelemetryExceptionsAndAddUniqueIndex.js +106 -0
  28. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1779900000000-DedupeTelemetryExceptionsAndAddUniqueIndex.js.map +1 -0
  29. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +4 -0
  30. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  31. package/build/dist/Server/Services/ExceptionAggregationService.js +44 -4
  32. package/build/dist/Server/Services/ExceptionAggregationService.js.map +1 -1
  33. package/build/dist/Server/Services/LogAggregationService.js.map +1 -1
  34. package/build/dist/Server/Services/MetricAggregationService.js +159 -0
  35. package/build/dist/Server/Services/MetricAggregationService.js.map +1 -0
  36. package/build/dist/Server/Services/OpenTelemetryIngestService.js +60 -3
  37. package/build/dist/Server/Services/OpenTelemetryIngestService.js.map +1 -1
  38. package/build/dist/Server/Services/TraceAggregationService.js.map +1 -1
  39. package/build/dist/Server/Utils/Monitor/MonitorLogUtil.js +127 -4
  40. package/build/dist/Server/Utils/Monitor/MonitorLogUtil.js.map +1 -1
  41. package/build/dist/Server/Utils/Telemetry/ResourceFacetResolver.js +204 -0
  42. package/build/dist/Server/Utils/Telemetry/ResourceFacetResolver.js.map +1 -0
  43. package/build/dist/UI/Components/LogsViewer/LogsViewer.js +1 -1
  44. package/build/dist/UI/Components/LogsViewer/LogsViewer.js.map +1 -1
  45. package/build/dist/UI/Components/LogsViewer/components/FacetSection.js +26 -6
  46. package/build/dist/UI/Components/LogsViewer/components/FacetSection.js.map +1 -1
  47. package/build/dist/UI/Components/LogsViewer/components/LogsFacetSidebar.js +12 -1
  48. package/build/dist/UI/Components/LogsViewer/components/LogsFacetSidebar.js.map +1 -1
  49. package/build/dist/UI/Components/LogsViewer/types.js.map +1 -1
  50. package/build/dist/UI/Components/TelemetryViewer/TelemetryViewer.js +1 -1
  51. package/build/dist/UI/Components/TelemetryViewer/TelemetryViewer.js.map +1 -1
  52. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryFacetSection.js +32 -6
  53. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryFacetSection.js.map +1 -1
  54. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryFacetSidebar.js +6 -1
  55. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryFacetSidebar.js.map +1 -1
  56. package/package.json +1 -1
@@ -0,0 +1,299 @@
1
+ import ObjectID from "../../../Types/ObjectID";
2
+ import PositiveNumber from "../../../Types/PositiveNumber";
3
+ import Search from "../../../Types/BaseDatabase/Search";
4
+ import MultiSearch from "../../../Types/BaseDatabase/MultiSearch";
5
+ import ServiceModel from "../../../Models/DatabaseModels/Service";
6
+ import HostModel from "../../../Models/DatabaseModels/Host";
7
+ import DockerHostModel from "../../../Models/DatabaseModels/DockerHost";
8
+ import KubernetesClusterModel from "../../../Models/DatabaseModels/KubernetesCluster";
9
+ import ServiceService from "../../Services/ServiceService";
10
+ import HostService from "../../Services/HostService";
11
+ import DockerHostService from "../../Services/DockerHostService";
12
+ import KubernetesClusterService from "../../Services/KubernetesClusterService";
13
+ import CaptureSpan from "./CaptureSpan";
14
+
15
+ /*
16
+ * Facet keys whose values are entity IDs backed by a Postgres source-of-truth
17
+ * table. ClickHouse aggregation only sees IDs that appear in the sampled
18
+ * trace/log window, so low-volume resources never reach the filter sidebar.
19
+ * Resolving the value list from Postgres instead means every project resource
20
+ * shows up regardless of recent telemetry activity, and the sidebar search
21
+ * matches across the full set (not just the loaded subset).
22
+ */
23
+ export const RESOURCE_FACET_KEYS: ReadonlySet<string> = new Set([
24
+ "serviceId",
25
+ "hostId",
26
+ "dockerHostId",
27
+ "kubernetesClusterId",
28
+ ]);
29
+
30
+ export interface ResourceFacetSpec {
31
+ facetKey: string;
32
+ counts: Map<string, number>;
33
+ searchText?: string | undefined;
34
+ limit?: number | undefined;
35
+ }
36
+
37
+ export interface ResolvedFacetValue {
38
+ value: string;
39
+ count: number;
40
+ displayName: string;
41
+ }
42
+
43
+ export default class ResourceFacetResolver {
44
+ private static readonly DEFAULT_LIMIT: number = 500;
45
+
46
+ public static isResourceFacet(facetKey: string): boolean {
47
+ return RESOURCE_FACET_KEYS.has(facetKey);
48
+ }
49
+
50
+ @CaptureSpan()
51
+ public static async resolve(
52
+ projectId: ObjectID,
53
+ specs: Array<ResourceFacetSpec>,
54
+ ): Promise<Record<string, Array<ResolvedFacetValue>>> {
55
+ const results: Array<readonly [string, Array<ResolvedFacetValue>]> =
56
+ await Promise.all(
57
+ specs.map(
58
+ async (
59
+ spec: ResourceFacetSpec,
60
+ ): Promise<readonly [string, Array<ResolvedFacetValue>]> => {
61
+ try {
62
+ const values: Array<ResolvedFacetValue> =
63
+ await ResourceFacetResolver.resolveOne(projectId, spec);
64
+ return [spec.facetKey, values] as const;
65
+ } catch {
66
+ return [spec.facetKey, []] as const;
67
+ }
68
+ },
69
+ ),
70
+ );
71
+
72
+ return Object.fromEntries(results);
73
+ }
74
+
75
+ private static async resolveOne(
76
+ projectId: ObjectID,
77
+ spec: ResourceFacetSpec,
78
+ ): Promise<Array<ResolvedFacetValue>> {
79
+ const limit: number = spec.limit ?? ResourceFacetResolver.DEFAULT_LIMIT;
80
+ const searchText: string | undefined =
81
+ spec.searchText && spec.searchText.trim().length > 0
82
+ ? spec.searchText.trim()
83
+ : undefined;
84
+
85
+ switch (spec.facetKey) {
86
+ case "serviceId":
87
+ return ResourceFacetResolver.queryServices(
88
+ projectId,
89
+ spec.counts,
90
+ searchText,
91
+ limit,
92
+ );
93
+ case "hostId":
94
+ return ResourceFacetResolver.queryHosts(
95
+ projectId,
96
+ spec.counts,
97
+ searchText,
98
+ limit,
99
+ );
100
+ case "dockerHostId":
101
+ return ResourceFacetResolver.queryDockerHosts(
102
+ projectId,
103
+ spec.counts,
104
+ searchText,
105
+ limit,
106
+ );
107
+ case "kubernetesClusterId":
108
+ return ResourceFacetResolver.queryKubernetesClusters(
109
+ projectId,
110
+ spec.counts,
111
+ searchText,
112
+ limit,
113
+ );
114
+ default:
115
+ return [];
116
+ }
117
+ }
118
+
119
+ private static async queryServices(
120
+ projectId: ObjectID,
121
+ counts: Map<string, number>,
122
+ searchText: string | undefined,
123
+ limit: number,
124
+ ): Promise<Array<ResolvedFacetValue>> {
125
+ const query: Record<string, unknown> = { projectId };
126
+ if (searchText) {
127
+ query["name"] = new Search<string>(searchText);
128
+ }
129
+
130
+ const services: Array<ServiceModel> = await ServiceService.findBy({
131
+ query: query as any,
132
+ select: {
133
+ _id: true,
134
+ name: true,
135
+ },
136
+ limit: new PositiveNumber(limit),
137
+ skip: new PositiveNumber(0),
138
+ props: { isRoot: true },
139
+ });
140
+
141
+ return ResourceFacetResolver.mergeCounts(
142
+ services.map((s: ServiceModel): { id: string; displayName: string } => {
143
+ return {
144
+ id: s._id ? s._id.toString() : "",
145
+ displayName: s.name || "Unknown",
146
+ };
147
+ }),
148
+ counts,
149
+ );
150
+ }
151
+
152
+ private static async queryHosts(
153
+ projectId: ObjectID,
154
+ counts: Map<string, number>,
155
+ searchText: string | undefined,
156
+ limit: number,
157
+ ): Promise<Array<ResolvedFacetValue>> {
158
+ const query: Record<string, unknown> = { projectId };
159
+ if (searchText) {
160
+ query["name"] = new MultiSearch({
161
+ fields: ["name", "hostIdentifier"],
162
+ value: searchText,
163
+ });
164
+ }
165
+
166
+ const hosts: Array<HostModel> = await HostService.findBy({
167
+ query: query as any,
168
+ select: {
169
+ _id: true,
170
+ name: true,
171
+ hostIdentifier: true,
172
+ },
173
+ limit: new PositiveNumber(limit),
174
+ skip: new PositiveNumber(0),
175
+ props: { isRoot: true },
176
+ });
177
+
178
+ return ResourceFacetResolver.mergeCounts(
179
+ hosts.map((h: HostModel): { id: string; displayName: string } => {
180
+ return {
181
+ id: h._id ? h._id.toString() : "",
182
+ displayName: h.name || h.hostIdentifier || "Unknown",
183
+ };
184
+ }),
185
+ counts,
186
+ );
187
+ }
188
+
189
+ private static async queryDockerHosts(
190
+ projectId: ObjectID,
191
+ counts: Map<string, number>,
192
+ searchText: string | undefined,
193
+ limit: number,
194
+ ): Promise<Array<ResolvedFacetValue>> {
195
+ const query: Record<string, unknown> = { projectId };
196
+ if (searchText) {
197
+ query["name"] = new MultiSearch({
198
+ fields: ["name", "hostIdentifier"],
199
+ value: searchText,
200
+ });
201
+ }
202
+
203
+ const dockerHosts: Array<DockerHostModel> = await DockerHostService.findBy({
204
+ query: query as any,
205
+ select: {
206
+ _id: true,
207
+ name: true,
208
+ hostIdentifier: true,
209
+ },
210
+ limit: new PositiveNumber(limit),
211
+ skip: new PositiveNumber(0),
212
+ props: { isRoot: true },
213
+ });
214
+
215
+ return ResourceFacetResolver.mergeCounts(
216
+ dockerHosts.map(
217
+ (d: DockerHostModel): { id: string; displayName: string } => {
218
+ return {
219
+ id: d._id ? d._id.toString() : "",
220
+ displayName: d.name || d.hostIdentifier || "Unknown",
221
+ };
222
+ },
223
+ ),
224
+ counts,
225
+ );
226
+ }
227
+
228
+ private static async queryKubernetesClusters(
229
+ projectId: ObjectID,
230
+ counts: Map<string, number>,
231
+ searchText: string | undefined,
232
+ limit: number,
233
+ ): Promise<Array<ResolvedFacetValue>> {
234
+ const query: Record<string, unknown> = { projectId };
235
+ if (searchText) {
236
+ query["name"] = new MultiSearch({
237
+ fields: ["name", "clusterIdentifier"],
238
+ value: searchText,
239
+ });
240
+ }
241
+
242
+ const clusters: Array<KubernetesClusterModel> =
243
+ await KubernetesClusterService.findBy({
244
+ query: query as any,
245
+ select: {
246
+ _id: true,
247
+ name: true,
248
+ clusterIdentifier: true,
249
+ },
250
+ limit: new PositiveNumber(limit),
251
+ skip: new PositiveNumber(0),
252
+ props: { isRoot: true },
253
+ });
254
+
255
+ return ResourceFacetResolver.mergeCounts(
256
+ clusters.map(
257
+ (c: KubernetesClusterModel): { id: string; displayName: string } => {
258
+ return {
259
+ id: c._id ? c._id.toString() : "",
260
+ displayName: c.name || c.clusterIdentifier || "Unknown",
261
+ };
262
+ },
263
+ ),
264
+ counts,
265
+ );
266
+ }
267
+
268
+ /*
269
+ * Combine Postgres-sourced entities with counts from the ClickHouse sample.
270
+ * Entities without a count default to 0 (they exist in the project but
271
+ * had no telemetry in the active window). Sorts active-first so the
272
+ * highest-traffic resources surface at the top of the sidebar.
273
+ */
274
+ private static mergeCounts(
275
+ entities: Array<{ id: string; displayName: string }>,
276
+ counts: Map<string, number>,
277
+ ): Array<ResolvedFacetValue> {
278
+ const out: Array<ResolvedFacetValue> = entities
279
+ .filter((e: { id: string }): boolean => {
280
+ return e.id.length > 0;
281
+ })
282
+ .map((e: { id: string; displayName: string }): ResolvedFacetValue => {
283
+ return {
284
+ value: e.id,
285
+ count: counts.get(e.id) || 0,
286
+ displayName: e.displayName,
287
+ };
288
+ });
289
+
290
+ out.sort((a: ResolvedFacetValue, b: ResolvedFacetValue): number => {
291
+ if (a.count !== b.count) {
292
+ return b.count - a.count;
293
+ }
294
+ return a.displayName.localeCompare(b.displayName);
295
+ });
296
+
297
+ return out;
298
+ }
299
+ }
@@ -89,6 +89,15 @@ export interface ComponentProps {
89
89
  facetLoading?: boolean;
90
90
  onFacetInclude?: (facetKey: string, value: string) => void;
91
91
  onFacetExclude?: (facetKey: string, value: string) => void;
92
+ /*
93
+ * Debounced search emit for resource facets (serviceId / hostId / etc.).
94
+ * Parent updates state and re-fetches facets so the result includes
95
+ * matching resources from the Postgres source-of-truth, not just those
96
+ * already loaded into the sidebar.
97
+ */
98
+ onFacetSearchChange?:
99
+ | ((facetKey: string, searchText: string) => void)
100
+ | undefined;
92
101
  showFacetSidebar?: boolean;
93
102
  activeFilters?: Array<ActiveFilter> | undefined;
94
103
  baseActiveFilters?: Array<ActiveFilter> | undefined;
@@ -1063,6 +1072,7 @@ const LogsViewer: FunctionComponent<ComponentProps> = (
1063
1072
  savedViews={props.savedViews}
1064
1073
  selectedSavedViewId={props.selectedSavedViewId}
1065
1074
  onSavedViewSelect={props.onSavedViewSelect}
1075
+ onFacetSearchChange={props.onFacetSearchChange}
1066
1076
  />
1067
1077
  )}
1068
1078
 
@@ -1,6 +1,8 @@
1
1
  import React, {
2
2
  FunctionComponent,
3
3
  ReactElement,
4
+ useEffect,
5
+ useRef,
4
6
  useState,
5
7
  useMemo,
6
8
  } from "react";
@@ -19,10 +21,17 @@ export interface FacetSectionProps {
19
21
  valueDisplayMap?: Record<string, string> | undefined;
20
22
  valueColorMap?: Record<string, string> | undefined;
21
23
  activeValues?: Set<string> | undefined;
24
+ /*
25
+ * When set, the search box also emits typed text to the parent (debounced)
26
+ * so it can refetch values from the backend. Client-side filtering still
27
+ * runs as defense-in-depth.
28
+ */
29
+ onSearchChange?: ((text: string) => void) | undefined;
22
30
  }
23
31
 
24
32
  const DEFAULT_VISIBLE_COUNT: number = 5;
25
33
  const SEARCH_THRESHOLD: number = 6;
34
+ const SEARCH_DEBOUNCE_MS: number = 300;
26
35
 
27
36
  const FacetSection: FunctionComponent<FacetSectionProps> = (
28
37
  props: FacetSectionProps,
@@ -31,7 +40,31 @@ const FacetSection: FunctionComponent<FacetSectionProps> = (
31
40
  const [showAll, setShowAll] = useState<boolean>(false);
32
41
  const [searchText, setSearchText] = useState<string>("");
33
42
 
34
- const showSearch: boolean = props.values.length >= SEARCH_THRESHOLD;
43
+ const showSearch: boolean =
44
+ props.onSearchChange !== undefined ||
45
+ props.values.length >= SEARCH_THRESHOLD;
46
+
47
+ const onSearchChange: FacetSectionProps["onSearchChange"] =
48
+ props.onSearchChange;
49
+ const lastEmittedRef: React.MutableRefObject<string | null> = useRef<
50
+ string | null
51
+ >(null);
52
+ useEffect(() => {
53
+ if (!onSearchChange) {
54
+ return;
55
+ }
56
+ const trimmed: string = searchText.trim();
57
+ if (lastEmittedRef.current === trimmed) {
58
+ return;
59
+ }
60
+ const handle: ReturnType<typeof setTimeout> = setTimeout(() => {
61
+ lastEmittedRef.current = trimmed;
62
+ onSearchChange(trimmed);
63
+ }, SEARCH_DEBOUNCE_MS);
64
+ return () => {
65
+ clearTimeout(handle);
66
+ };
67
+ }, [searchText, onSearchChange]);
35
68
 
36
69
  const filteredValues: Array<FacetValue> = useMemo(() => {
37
70
  if (!searchText.trim()) {
@@ -40,7 +73,9 @@ const FacetSection: FunctionComponent<FacetSectionProps> = (
40
73
  const query: string = searchText.toLowerCase().trim();
41
74
  return props.values.filter((facet: FacetValue) => {
42
75
  const displayName: string =
43
- props.valueDisplayMap?.[facet.value] ?? facet.value;
76
+ facet.displayName ??
77
+ props.valueDisplayMap?.[facet.value] ??
78
+ facet.value;
44
79
  return displayName.toLowerCase().includes(query);
45
80
  });
46
81
  }, [props.values, props.valueDisplayMap, searchText]);
@@ -114,7 +149,9 @@ const FacetSection: FunctionComponent<FacetSectionProps> = (
114
149
  <FacetValueRow
115
150
  key={facet.value}
116
151
  value={facet.value}
117
- displayValue={props.valueDisplayMap?.[facet.value]}
152
+ displayValue={
153
+ facet.displayName ?? props.valueDisplayMap?.[facet.value]
154
+ }
118
155
  count={facet.count}
119
156
  maxCount={maxCount}
120
157
  color={props.valueColorMap?.[facet.value]}
@@ -28,8 +28,23 @@ export interface LogsFacetSidebarProps {
28
28
  savedViews?: Array<LogsSavedViewOption> | undefined;
29
29
  selectedSavedViewId?: string | null | undefined;
30
30
  onSavedViewSelect?: ((viewId: string) => void) | undefined;
31
+ /*
32
+ * Called (debounced) when typing in a resource facet's search box. Lets
33
+ * the parent re-issue the facets request with the typed text scoped to
34
+ * that facet, so the result includes resources beyond the loaded subset.
35
+ */
36
+ onFacetSearchChange?:
37
+ | ((facetKey: string, searchText: string) => void)
38
+ | undefined;
31
39
  }
32
40
 
41
+ const RESOURCE_FACET_KEYS: ReadonlySet<string> = new Set([
42
+ "serviceId",
43
+ "hostId",
44
+ "dockerHostId",
45
+ "kubernetesClusterId",
46
+ ]);
47
+
33
48
  const SEVERITY_ORDER: Array<string> = [
34
49
  LogSeverity.Fatal,
35
50
  LogSeverity.Error,
@@ -278,6 +293,13 @@ const LogsFacetSidebar: FunctionComponent<LogsFacetSidebarProps> = (
278
293
  valueColorMap = severityColorMap;
279
294
  }
280
295
 
296
+ const onSearchChange: ((text: string) => void) | undefined =
297
+ RESOURCE_FACET_KEYS.has(key) && props.onFacetSearchChange
298
+ ? (text: string) => {
299
+ props.onFacetSearchChange!(key, text);
300
+ }
301
+ : undefined;
302
+
281
303
  return (
282
304
  <FacetSection
283
305
  key={key}
@@ -289,6 +311,7 @@ const LogsFacetSidebar: FunctionComponent<LogsFacetSidebarProps> = (
289
311
  valueDisplayMap={valueDisplayMap}
290
312
  valueColorMap={valueColorMap}
291
313
  activeValues={activeValuesByKey[key]}
314
+ onSearchChange={onSearchChange}
292
315
  />
293
316
  );
294
317
  })}
@@ -13,6 +13,8 @@ export interface HistogramBucket {
13
13
  export interface FacetValue {
14
14
  value: string;
15
15
  count: number;
16
+ // Server-resolved display name (e.g. serviceId → service name).
17
+ displayName?: string | undefined;
16
18
  }
17
19
 
18
20
  export type FacetData = Record<string, Array<FacetValue>>;
@@ -70,6 +70,13 @@ export interface TelemetryViewerProps<T> {
70
70
  facetLoading?: boolean;
71
71
  onFacetInclude?: ((facetKey: string, value: string) => void) | undefined;
72
72
  onFacetExclude?: ((facetKey: string, value: string) => void) | undefined;
73
+ /*
74
+ * Called (debounced) when the user types in a server-searchable facet's
75
+ * search box. Parent typically updates state and refetches facetData.
76
+ */
77
+ onFacetSearchChange?:
78
+ | ((facetKey: string, searchText: string) => void)
79
+ | undefined;
73
80
 
74
81
  // -- Active filters --
75
82
  activeFilters?: Array<ActiveFilter> | undefined;
@@ -219,6 +226,7 @@ function TelemetryViewerInner<T>(props: TelemetryViewerProps<T>): ReactElement {
219
226
  onExcludeFilter={(key: string, value: string) => {
220
227
  props.onFacetExclude?.(key, value);
221
228
  }}
229
+ onFacetSearchChange={props.onFacetSearchChange}
222
230
  />
223
231
  )}
224
232
 
@@ -1,6 +1,8 @@
1
1
  import React, {
2
2
  FunctionComponent,
3
3
  ReactElement,
4
+ useEffect,
5
+ useRef,
4
6
  useState,
5
7
  useMemo,
6
8
  } from "react";
@@ -20,10 +22,20 @@ export interface TelemetryFacetSectionProps {
20
22
  valueColorMap?: Record<string, string> | undefined;
21
23
  activeValues?: Set<string> | undefined;
22
24
  defaultExpanded?: boolean;
25
+ /*
26
+ * When set, the search box also emits typed text to the parent (debounced)
27
+ * so it can refetch values from the backend. Client-side filtering still
28
+ * runs as defense-in-depth — the server response replaces props.values
29
+ * which then flows through the local filter.
30
+ */
31
+ onSearchChange?: ((text: string) => void) | undefined;
32
+ // Force the search input to render even when below the local item threshold.
33
+ alwaysShowSearch?: boolean;
23
34
  }
24
35
 
25
36
  const DEFAULT_VISIBLE_COUNT: number = 5;
26
37
  const SEARCH_THRESHOLD: number = 6;
38
+ const SEARCH_DEBOUNCE_MS: number = 300;
27
39
 
28
40
  const TelemetryFacetSection: FunctionComponent<TelemetryFacetSectionProps> = (
29
41
  props: TelemetryFacetSectionProps,
@@ -34,7 +46,37 @@ const TelemetryFacetSection: FunctionComponent<TelemetryFacetSectionProps> = (
34
46
  const [showAll, setShowAll] = useState<boolean>(false);
35
47
  const [searchText, setSearchText] = useState<string>("");
36
48
 
37
- const showSearch: boolean = props.values.length >= SEARCH_THRESHOLD;
49
+ const showSearch: boolean =
50
+ props.alwaysShowSearch === true ||
51
+ props.onSearchChange !== undefined ||
52
+ props.values.length >= SEARCH_THRESHOLD;
53
+
54
+ /*
55
+ * Debounce the upward search emit so each keystroke doesn't fire a
56
+ * round-trip. Local filter still applies immediately for fast feedback
57
+ * against the currently loaded set.
58
+ */
59
+ const onSearchChange: TelemetryFacetSectionProps["onSearchChange"] =
60
+ props.onSearchChange;
61
+ const lastEmittedRef: React.MutableRefObject<string | null> = useRef<
62
+ string | null
63
+ >(null);
64
+ useEffect(() => {
65
+ if (!onSearchChange) {
66
+ return;
67
+ }
68
+ const trimmed: string = searchText.trim();
69
+ if (lastEmittedRef.current === trimmed) {
70
+ return;
71
+ }
72
+ const handle: ReturnType<typeof setTimeout> = setTimeout(() => {
73
+ lastEmittedRef.current = trimmed;
74
+ onSearchChange(trimmed);
75
+ }, SEARCH_DEBOUNCE_MS);
76
+ return () => {
77
+ clearTimeout(handle);
78
+ };
79
+ }, [searchText, onSearchChange]);
38
80
 
39
81
  const filteredValues: Array<FacetValue> = useMemo(() => {
40
82
  if (!searchText.trim()) {
@@ -43,7 +85,9 @@ const TelemetryFacetSection: FunctionComponent<TelemetryFacetSectionProps> = (
43
85
  const query: string = searchText.toLowerCase().trim();
44
86
  return props.values.filter((facet: FacetValue) => {
45
87
  const displayName: string =
46
- props.valueDisplayMap?.[facet.value] ?? facet.value;
88
+ facet.displayName ??
89
+ props.valueDisplayMap?.[facet.value] ??
90
+ facet.value;
47
91
  return displayName.toLowerCase().includes(query);
48
92
  });
49
93
  }, [props.values, props.valueDisplayMap, searchText]);
@@ -117,7 +161,9 @@ const TelemetryFacetSection: FunctionComponent<TelemetryFacetSectionProps> = (
117
161
  <TelemetryFacetValueRow
118
162
  key={facet.value}
119
163
  value={facet.value}
120
- displayValue={props.valueDisplayMap?.[facet.value]}
164
+ displayValue={
165
+ facet.displayName ?? props.valueDisplayMap?.[facet.value]
166
+ }
121
167
  count={facet.count}
122
168
  maxCount={maxCount}
123
169
  color={props.valueColorMap?.[facet.value]}
@@ -12,6 +12,14 @@ export interface TelemetryFacetSidebarProps {
12
12
  onExcludeFilter: (facetKey: string, value: string) => void;
13
13
  activeFilters?: Array<ActiveFilter> | undefined;
14
14
  headerLabel?: string | undefined;
15
+ /*
16
+ * Called (debounced) when a search input changes for a facet whose config
17
+ * has serverSearchable=true. Lets the parent re-issue a facets request
18
+ * with the typed text scoped to that facet.
19
+ */
20
+ onFacetSearchChange?:
21
+ | ((facetKey: string, searchText: string) => void)
22
+ | undefined;
15
23
  }
16
24
 
17
25
  const TelemetryFacetSidebar: FunctionComponent<TelemetryFacetSidebarProps> = (
@@ -63,6 +71,13 @@ const TelemetryFacetSidebar: FunctionComponent<TelemetryFacetSidebarProps> = (
63
71
  {orderedConfigs.map((config: FacetConfig) => {
64
72
  const values: Array<FacetValue> = props.facetData[config.key] || [];
65
73
 
74
+ const onSearchChange: ((text: string) => void) | undefined =
75
+ config.serverSearchable && props.onFacetSearchChange
76
+ ? (text: string) => {
77
+ props.onFacetSearchChange!(config.key, text);
78
+ }
79
+ : undefined;
80
+
66
81
  return (
67
82
  <TelemetryFacetSection
68
83
  key={config.key}
@@ -74,6 +89,7 @@ const TelemetryFacetSidebar: FunctionComponent<TelemetryFacetSidebarProps> = (
74
89
  valueDisplayMap={config.valueDisplayMap}
75
90
  valueColorMap={config.valueColorMap}
76
91
  activeValues={activeValuesByKey[config.key]}
92
+ onSearchChange={onSearchChange}
77
93
  />
78
94
  );
79
95
  })}
@@ -6,6 +6,12 @@
6
6
  export interface FacetValue {
7
7
  value: string;
8
8
  count: number;
9
+ /*
10
+ * Optional server-resolved display name. Set by the backend when the facet
11
+ * value is resolved against a source-of-truth table (e.g. serviceId →
12
+ * service name). Falls back to the parent's valueDisplayMap if not present.
13
+ */
14
+ displayName?: string | undefined;
9
15
  }
10
16
 
11
17
  export type FacetData = Record<string, Array<FacetValue>>;
@@ -52,6 +58,12 @@ export interface FacetConfig {
52
58
  valueColorMap?: Record<string, string> | undefined;
53
59
  // Ordering priority: lower = shown first.
54
60
  priority?: number | undefined;
61
+ /*
62
+ * When true, the section's search box also emits typed text to the
63
+ * sidebar's `onFacetSearchChange` callback so the parent can refetch
64
+ * values from the backend (used for resource facets backed by Postgres).
65
+ */
66
+ serverSearchable?: boolean | undefined;
55
67
  }
56
68
 
57
69
  export interface SearchHelpRow {
@@ -1264,6 +1264,17 @@ TelemetryException = __decorate([
1264
1264
  name: "TelemetryException",
1265
1265
  }),
1266
1266
  Index(["projectId", "isResolved", "isArchived"]) // Exceptions dashboard counts/filters
1267
+ /*
1268
+ * Composite uniqueness on the dedup key used by the OTel traces ingest
1269
+ * batched upsert. The ingest path collapses every exception event in a
1270
+ * worker batch into a single INSERT … ON CONFLICT (projectId,
1271
+ * serviceId, fingerprint) DO UPDATE statement; this index is what makes
1272
+ * that conflict target resolvable and stops two concurrent workers from
1273
+ * racing the old findOneBy + update path into duplicate rows or lost
1274
+ * occuranceCount increments.
1275
+ */
1276
+ ,
1277
+ Index(["projectId", "serviceId", "fingerprint"], { unique: true })
1267
1278
  ], TelemetryException);
1268
1279
  export default TelemetryException;
1269
1280
  //# sourceMappingURL=TelemetryException.js.map