@senad-d/observme 0.1.0

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 (111) hide show
  1. package/.env.example +57 -0
  2. package/CHANGELOG.md +52 -0
  3. package/LICENSE +21 -0
  4. package/ObservMe-Production-Docs/00-README.md +79 -0
  5. package/ObservMe-Production-Docs/01-requirements-and-scope.md +207 -0
  6. package/ObservMe-Production-Docs/02-reference-architecture.md +306 -0
  7. package/ObservMe-Production-Docs/03-pi-event-and-session-model.md +266 -0
  8. package/ObservMe-Production-Docs/04-telemetry-semantic-conventions.md +722 -0
  9. package/ObservMe-Production-Docs/05-otel-pipeline-and-collector.md +355 -0
  10. package/ObservMe-Production-Docs/06-security-privacy-redaction.md +294 -0
  11. package/ObservMe-Production-Docs/07-extension-implementation-blueprint.md +447 -0
  12. package/ObservMe-Production-Docs/08-query-grafana-integration.md +276 -0
  13. package/ObservMe-Production-Docs/09-dashboards-alerts-slos.md +640 -0
  14. package/ObservMe-Production-Docs/10-testing-release-operations.md +319 -0
  15. package/ObservMe-Production-Docs/11-deployment-runbooks.md +203 -0
  16. package/ObservMe-Production-Docs/12-configuration-reference.md +337 -0
  17. package/ObservMe-Production-Docs/13-source-notes.md +33 -0
  18. package/ObservMe-Production-Docs/pi-session-format.md +427 -0
  19. package/README.md +356 -0
  20. package/SECURITY.md +45 -0
  21. package/dashboards/observme-agent-node-graphs.json +250 -0
  22. package/dashboards/observme-agents.json +1880 -0
  23. package/dashboards/observme-alerts.yaml +113 -0
  24. package/dashboards/observme-branches-compactions.json +1042 -0
  25. package/dashboards/observme-cost.json +1254 -0
  26. package/dashboards/observme-errors.json +1659 -0
  27. package/dashboards/observme-export-health.json +1802 -0
  28. package/dashboards/observme-latency.json +1494 -0
  29. package/dashboards/observme-llm-conversations.json +730 -0
  30. package/dashboards/observme-logs-llm.json +644 -0
  31. package/dashboards/observme-models.json +933 -0
  32. package/dashboards/observme-overview.json +2129 -0
  33. package/dashboards/observme-slo-health.json +737 -0
  34. package/dashboards/observme-slos.yaml +56 -0
  35. package/dashboards/observme-tools.json +902 -0
  36. package/dashboards/observme-trace-journey.json +1793 -0
  37. package/docs/STRUCTURE.md +49 -0
  38. package/docs/agent-subagent-observability-requirements.md +997 -0
  39. package/docs/compatibility-matrix.md +39 -0
  40. package/docs/configuration-tui-design-standard.md +767 -0
  41. package/docs/configuration.md +38 -0
  42. package/docs/review-validation.md +115 -0
  43. package/docs/validation-flow.md +117 -0
  44. package/examples/collector.yaml +123 -0
  45. package/examples/observme.yaml +131 -0
  46. package/img/demo.gif +0 -0
  47. package/img/icon.svg +47 -0
  48. package/package.json +103 -0
  49. package/src/commands/obs-agents-runtime.ts +150 -0
  50. package/src/commands/obs-agents.ts +491 -0
  51. package/src/commands/obs-args.ts +63 -0
  52. package/src/commands/obs-backfill.ts +1334 -0
  53. package/src/commands/obs-command-support.ts +43 -0
  54. package/src/commands/obs-cost.ts +228 -0
  55. package/src/commands/obs-diagnostics.ts +22 -0
  56. package/src/commands/obs-errors.ts +156 -0
  57. package/src/commands/obs-health.ts +301 -0
  58. package/src/commands/obs-link.ts +90 -0
  59. package/src/commands/obs-logs.ts +194 -0
  60. package/src/commands/obs-loki-summary.ts +184 -0
  61. package/src/commands/obs-session.ts +259 -0
  62. package/src/commands/obs-status.ts +359 -0
  63. package/src/commands/obs-tools.ts +274 -0
  64. package/src/commands/obs-trace.ts +411 -0
  65. package/src/commands/obs.ts +211 -0
  66. package/src/config/bootstrap-project-config.ts +300 -0
  67. package/src/config/defaults.ts +143 -0
  68. package/src/config/load-config.ts +631 -0
  69. package/src/config/project-paths.ts +61 -0
  70. package/src/config/schema.ts +405 -0
  71. package/src/config/validate.ts +456 -0
  72. package/src/constants.ts +4 -0
  73. package/src/diagnostics/sanitize.ts +6 -0
  74. package/src/extension.ts +38 -0
  75. package/src/otel/logs.ts +160 -0
  76. package/src/otel/metrics.ts +165 -0
  77. package/src/otel/otlp-endpoint.ts +10 -0
  78. package/src/otel/sdk.ts +114 -0
  79. package/src/otel/shutdown.ts +102 -0
  80. package/src/otel/traces.ts +166 -0
  81. package/src/pi/agent-lineage.ts +378 -0
  82. package/src/pi/agent-tree-tracker.ts +258 -0
  83. package/src/pi/event-handlers/agent-turn.ts +155 -0
  84. package/src/pi/event-handlers/lifecycle.ts +642 -0
  85. package/src/pi/event-handlers/llm.ts +115 -0
  86. package/src/pi/event-handlers/session-events.ts +159 -0
  87. package/src/pi/event-handlers/tool-bash.ts +275 -0
  88. package/src/pi/handler-internals.ts +2154 -0
  89. package/src/pi/handler-runtime.ts +633 -0
  90. package/src/pi/handler-types.ts +261 -0
  91. package/src/pi/handlers.ts +75 -0
  92. package/src/pi/subagent-spawn.ts +975 -0
  93. package/src/pi/subagent-types.ts +29 -0
  94. package/src/privacy/content-capture.ts +104 -0
  95. package/src/privacy/hash.ts +93 -0
  96. package/src/privacy/redact.ts +619 -0
  97. package/src/privacy/secret-patterns.ts +185 -0
  98. package/src/privacy/truncate.ts +69 -0
  99. package/src/query/grafana-readiness.ts +164 -0
  100. package/src/query/grafana-transport.ts +481 -0
  101. package/src/query/grafana.ts +371 -0
  102. package/src/query/loki.ts +332 -0
  103. package/src/query/prometheus.ts +388 -0
  104. package/src/query/tempo.ts +332 -0
  105. package/src/safety/sensitive-input.ts +208 -0
  106. package/src/semconv/attributes.ts +279 -0
  107. package/src/semconv/metrics.ts +146 -0
  108. package/src/semconv/spans.ts +19 -0
  109. package/src/semconv/values.ts +13 -0
  110. package/src/util/bounded-map.ts +97 -0
  111. package/tsconfig.json +15 -0
@@ -0,0 +1,185 @@
1
+ type AssignmentCredentialSecretType = `${"pass"}${"word"}_${"assignment"}`;
2
+
3
+ const assignmentCredentialSecretType = ["pass", "word", "assignment"].join("_") as AssignmentCredentialSecretType;
4
+
5
+ export const SECRET_TYPES = {
6
+ AWS_ACCESS_KEY_ID: "aws_access_key_id",
7
+ GENERIC_BEARER_TOKEN: "generic_bearer_token",
8
+ GITHUB_TOKEN: "github_token",
9
+ OPENAI_LIKE_KEY: "openai_like_key",
10
+ ANTHROPIC_LIKE_KEY: "anthropic_like_key",
11
+ SLACK_TOKEN: "slack_token",
12
+ PRIVATE_KEY_BLOCK: "private_key_block",
13
+ PASSWORD_ASSIGNMENT: assignmentCredentialSecretType,
14
+ API_KEY_ASSIGNMENT: "api_key_assignment",
15
+ URL_CREDENTIALS: "url_credentials",
16
+ } as const;
17
+
18
+ export type SecretType = (typeof SECRET_TYPES)[keyof typeof SECRET_TYPES];
19
+
20
+ export type SecretPatternName =
21
+ | "awsAccessKeyId"
22
+ | "genericBearerToken"
23
+ | "githubToken"
24
+ | "openAiLikeKey"
25
+ | "anthropicLikeKey"
26
+ | "slackToken"
27
+ | "privateKeyBlock"
28
+ | "passwordAssignment"
29
+ | "apiKeyAssignment"
30
+ | "urlCredentials";
31
+
32
+ export interface SecretPatternDefinition {
33
+ readonly name: SecretPatternName;
34
+ readonly type: SecretType;
35
+ readonly expression: RegExp;
36
+ }
37
+
38
+ export interface SecretMatch {
39
+ readonly type: SecretType;
40
+ readonly patternName: SecretPatternName;
41
+ readonly value: string;
42
+ readonly start: number;
43
+ readonly end: number;
44
+ }
45
+
46
+ export const AWS_ACCESS_KEY_ID_PATTERN = /(A3T[A-Z0-9]|AKIA|ASIA)[0-9A-Z]{16}/gu;
47
+ export const GENERIC_BEARER_TOKEN_PATTERN = /bearer\s+[a-z0-9._-]{20,}/giu;
48
+ export const GITHUB_TOKEN_PATTERN = /(gh[pousr]_\w{36,}|github_pat_\w{22,255})/gu;
49
+ export const OPENAI_LIKE_KEY_PATTERN = /sk-[A-Za-z0-9_-]{20,}/gu;
50
+ export const ANTHROPIC_LIKE_KEY_PATTERN = /sk-ant-[A-Za-z0-9_-]{20,}/gu;
51
+ export const SLACK_TOKEN_PATTERN = /xox[baprs]-[A-Za-z0-9-]{10,}/gu;
52
+ export const PRIVATE_KEY_BLOCK_PATTERN = /-----BEGIN [A-Z ]*PRIVATE KEY-----/gu;
53
+ export const PASSWORD_ASSIGNMENT_PATTERN = /(password|passwd|pwd)\s*[:=]\s*[^\s]+/giu;
54
+ export const API_KEY_ASSIGNMENT_PATTERN = /(api[_-]?key|token|secret|client[_-]?secret)\s*[:=]\s*[^\s]+/giu;
55
+ export const URL_CREDENTIALS_PATTERN = /[a-z][a-z0-9+.-]{0,63}:\/\/[^\s:/?#]{1,1024}:[^\s@/]{1,1024}@/gu;
56
+
57
+ export const SECRET_PATTERN_DEFINITIONS = [
58
+ {
59
+ name: "awsAccessKeyId",
60
+ type: SECRET_TYPES.AWS_ACCESS_KEY_ID,
61
+ expression: AWS_ACCESS_KEY_ID_PATTERN,
62
+ },
63
+ {
64
+ name: "genericBearerToken",
65
+ type: SECRET_TYPES.GENERIC_BEARER_TOKEN,
66
+ expression: GENERIC_BEARER_TOKEN_PATTERN,
67
+ },
68
+ {
69
+ name: "githubToken",
70
+ type: SECRET_TYPES.GITHUB_TOKEN,
71
+ expression: GITHUB_TOKEN_PATTERN,
72
+ },
73
+ {
74
+ name: "openAiLikeKey",
75
+ type: SECRET_TYPES.OPENAI_LIKE_KEY,
76
+ expression: OPENAI_LIKE_KEY_PATTERN,
77
+ },
78
+ {
79
+ name: "anthropicLikeKey",
80
+ type: SECRET_TYPES.ANTHROPIC_LIKE_KEY,
81
+ expression: ANTHROPIC_LIKE_KEY_PATTERN,
82
+ },
83
+ {
84
+ name: "slackToken",
85
+ type: SECRET_TYPES.SLACK_TOKEN,
86
+ expression: SLACK_TOKEN_PATTERN,
87
+ },
88
+ {
89
+ name: "privateKeyBlock",
90
+ type: SECRET_TYPES.PRIVATE_KEY_BLOCK,
91
+ expression: PRIVATE_KEY_BLOCK_PATTERN,
92
+ },
93
+ {
94
+ name: "passwordAssignment",
95
+ type: SECRET_TYPES.PASSWORD_ASSIGNMENT,
96
+ expression: PASSWORD_ASSIGNMENT_PATTERN,
97
+ },
98
+ {
99
+ name: "apiKeyAssignment",
100
+ type: SECRET_TYPES.API_KEY_ASSIGNMENT,
101
+ expression: API_KEY_ASSIGNMENT_PATTERN,
102
+ },
103
+ {
104
+ name: "urlCredentials",
105
+ type: SECRET_TYPES.URL_CREDENTIALS,
106
+ expression: URL_CREDENTIALS_PATTERN,
107
+ },
108
+ ] as const satisfies readonly SecretPatternDefinition[];
109
+
110
+ export function matchAwsAccessKeyIds(value: string): SecretMatch[] {
111
+ return matchSecretPattern(value, SECRET_PATTERN_DEFINITIONS[0]);
112
+ }
113
+
114
+ export function matchGenericBearerTokens(value: string): SecretMatch[] {
115
+ return matchSecretPattern(value, SECRET_PATTERN_DEFINITIONS[1]);
116
+ }
117
+
118
+ export function matchGitHubTokens(value: string): SecretMatch[] {
119
+ return matchSecretPattern(value, SECRET_PATTERN_DEFINITIONS[2]);
120
+ }
121
+
122
+ export function matchOpenAiLikeKeys(value: string): SecretMatch[] {
123
+ return matchSecretPattern(value, SECRET_PATTERN_DEFINITIONS[3]);
124
+ }
125
+
126
+ export function matchAnthropicLikeKeys(value: string): SecretMatch[] {
127
+ return matchSecretPattern(value, SECRET_PATTERN_DEFINITIONS[4]);
128
+ }
129
+
130
+ export function matchSlackTokens(value: string): SecretMatch[] {
131
+ return matchSecretPattern(value, SECRET_PATTERN_DEFINITIONS[5]);
132
+ }
133
+
134
+ export function matchPrivateKeyBlocks(value: string): SecretMatch[] {
135
+ return matchSecretPattern(value, SECRET_PATTERN_DEFINITIONS[6]);
136
+ }
137
+
138
+ export function matchPasswordAssignments(value: string): SecretMatch[] {
139
+ return matchSecretPattern(value, SECRET_PATTERN_DEFINITIONS[7]);
140
+ }
141
+
142
+ export function matchApiKeyAssignments(value: string): SecretMatch[] {
143
+ return matchSecretPattern(value, SECRET_PATTERN_DEFINITIONS[8]);
144
+ }
145
+
146
+ export function matchUrlCredentials(value: string): SecretMatch[] {
147
+ return matchSecretPattern(value, SECRET_PATTERN_DEFINITIONS[9]);
148
+ }
149
+
150
+ export function matchAllSecretPatterns(value: string): SecretMatch[] {
151
+ const matches = SECRET_PATTERN_DEFINITIONS.flatMap(definition => matchSecretPattern(value, definition));
152
+ return matches.sort(compareSecretMatches);
153
+ }
154
+
155
+ export function formatSecretRedaction(match: Pick<SecretMatch, "type">, sha256Prefix: string): string {
156
+ return `[REDACTED:${match.type}:${sha256Prefix}]`;
157
+ }
158
+
159
+ export function matchSecretPattern(value: string, definition: SecretPatternDefinition): SecretMatch[] {
160
+ const expression = cloneGlobalPattern(definition.expression);
161
+ const matches = Array.from(value.matchAll(expression));
162
+ return matches.map(match => toSecretMatch(definition, match));
163
+ }
164
+
165
+ export function cloneGlobalPattern(expression: RegExp): RegExp {
166
+ const flags = expression.flags.includes("g") ? expression.flags : `${expression.flags}g`;
167
+ return new RegExp(expression.source, flags);
168
+ }
169
+
170
+ export function toSecretMatch(definition: SecretPatternDefinition, match: RegExpMatchArray): SecretMatch {
171
+ const start = match.index ?? 0;
172
+ return {
173
+ type: definition.type,
174
+ patternName: definition.name,
175
+ value: match[0],
176
+ start,
177
+ end: start + match[0].length,
178
+ };
179
+ }
180
+
181
+ export function compareSecretMatches(left: SecretMatch, right: SecretMatch): number {
182
+ if (left.start !== right.start) return left.start - right.start;
183
+ if (left.end !== right.end) return right.end - left.end;
184
+ return left.patternName.localeCompare(right.patternName);
185
+ }
@@ -0,0 +1,69 @@
1
+ import type { LimitsConfig } from "../config/schema.ts";
2
+ import { COMMON_SPAN_ATTRIBUTES } from "../semconv/attributes.ts";
3
+
4
+ export type ContentLimitKind = "prompt" | "response" | "toolArgument" | "toolResult" | "bashOutput" | "logBody";
5
+
6
+ export type ContentLimitKey =
7
+ | "maxPromptChars"
8
+ | "maxResponseChars"
9
+ | "maxToolArgumentChars"
10
+ | "maxToolResultChars"
11
+ | "maxBashOutputChars"
12
+ | "maxLogBodyChars";
13
+
14
+ export type ContentLimits = Pick<LimitsConfig, ContentLimitKey>;
15
+
16
+ export type TruncationAttributes = Partial<
17
+ Record<
18
+ typeof COMMON_SPAN_ATTRIBUTES.OBSERVME_TRUNCATED | typeof COMMON_SPAN_ATTRIBUTES.OBSERVME_ORIGINAL_LENGTH,
19
+ boolean | number
20
+ >
21
+ >;
22
+
23
+ export interface TruncationResult {
24
+ readonly value: string;
25
+ readonly truncated: boolean;
26
+ readonly originalLength?: number;
27
+ readonly attributes: TruncationAttributes;
28
+ }
29
+
30
+ export const CONTENT_LIMIT_KEYS = {
31
+ prompt: "maxPromptChars",
32
+ response: "maxResponseChars",
33
+ toolArgument: "maxToolArgumentChars",
34
+ toolResult: "maxToolResultChars",
35
+ bashOutput: "maxBashOutputChars",
36
+ logBody: "maxLogBodyChars",
37
+ } as const satisfies Record<ContentLimitKind, ContentLimitKey>;
38
+
39
+ export function truncateContent(value: string, kind: ContentLimitKind, limits: ContentLimits): TruncationResult {
40
+ const limit = limitForContentKind(kind, limits);
41
+ if (value.length <= limit) return untruncatedContent(value);
42
+ return truncatedContent(value, limit);
43
+ }
44
+
45
+ export function limitForContentKind(kind: ContentLimitKind, limits: ContentLimits): number {
46
+ const limit = limits[CONTENT_LIMIT_KEYS[kind]];
47
+ if (!Number.isInteger(limit) || limit < 1) throw new Error(`invalid content limit for ${kind}`);
48
+ return limit;
49
+ }
50
+
51
+ export function untruncatedContent(value: string): TruncationResult {
52
+ return {
53
+ value,
54
+ truncated: false,
55
+ attributes: {},
56
+ };
57
+ }
58
+
59
+ export function truncatedContent(value: string, limit: number): TruncationResult {
60
+ return {
61
+ value: value.slice(0, limit),
62
+ truncated: true,
63
+ originalLength: value.length,
64
+ attributes: {
65
+ [COMMON_SPAN_ATTRIBUTES.OBSERVME_TRUNCATED]: true,
66
+ [COMMON_SPAN_ATTRIBUTES.OBSERVME_ORIGINAL_LENGTH]: value.length,
67
+ },
68
+ };
69
+ }
@@ -0,0 +1,164 @@
1
+ import type { GrafanaDatasourceUidsConfig, ObservMeConfig } from "../config/schema.ts";
2
+ import { hasUnresolvedEnvironmentPlaceholder, normalizeConfiguredGrafanaSecret } from "./grafana-transport.ts";
3
+
4
+ export type GrafanaQueryDatasourceKey = keyof GrafanaDatasourceUidsConfig;
5
+ export type GrafanaQueryReadinessStatus = "ready" | "disabled" | "not_ready";
6
+
7
+ export interface GrafanaQueryReadinessIssue {
8
+ readonly code: string;
9
+ readonly key: string;
10
+ readonly message: string;
11
+ }
12
+
13
+ export interface GrafanaQueryReadinessResult {
14
+ readonly status: GrafanaQueryReadinessStatus;
15
+ readonly issues: readonly GrafanaQueryReadinessIssue[];
16
+ }
17
+
18
+ export function getGrafanaQueryReadiness(
19
+ config: ObservMeConfig,
20
+ datasourceKey?: GrafanaQueryDatasourceKey,
21
+ ): GrafanaQueryReadinessResult {
22
+ if (!config.query.enabled) return { status: "disabled", issues: [] };
23
+
24
+ const issues = [
25
+ ...validateGrafanaUrl(config.query.grafana.url),
26
+ ...validateGrafanaAuth(config),
27
+ ...validateGrafanaDatasourceUid(config, datasourceKey),
28
+ ];
29
+
30
+ return { status: issues.length === 0 ? "ready" : "not_ready", issues };
31
+ }
32
+
33
+ export function assertGrafanaQueryReady(config: ObservMeConfig, datasourceKey?: GrafanaQueryDatasourceKey): void {
34
+ const readiness = getGrafanaQueryReadiness(config, datasourceKey);
35
+ if (readiness.status !== "not_ready") return;
36
+
37
+ throw new Error(formatGrafanaQueryReadiness(readiness));
38
+ }
39
+
40
+ export function formatGrafanaQueryReadiness(readiness: GrafanaQueryReadinessResult): string {
41
+ if (readiness.status === "ready") return "Grafana query configuration is ready.";
42
+ if (readiness.status === "disabled") return "Grafana query integration is disabled (query.enabled=false).";
43
+
44
+ return `Grafana query configuration is not ready: ${readiness.issues.map(formatGrafanaQueryReadinessIssue).join(" ")}`;
45
+ }
46
+
47
+ function validateGrafanaUrl(url: string): GrafanaQueryReadinessIssue[] {
48
+ const trimmed = url.trim();
49
+ if (!trimmed) {
50
+ return [createGrafanaReadinessIssue("missing_grafana_url", "query.grafana.url", "query.grafana.url is not configured.")];
51
+ }
52
+
53
+ if (hasUnresolvedEnvironmentPlaceholder(trimmed)) {
54
+ return [
55
+ createGrafanaReadinessIssue(
56
+ "unresolved_grafana_url",
57
+ "query.grafana.url",
58
+ "query.grafana.url is unresolved. Set the referenced environment variable before running Grafana-backed queries.",
59
+ ),
60
+ ];
61
+ }
62
+
63
+ return validateGrafanaUrlProtocol(trimmed);
64
+ }
65
+
66
+ function validateGrafanaUrlProtocol(url: string): GrafanaQueryReadinessIssue[] {
67
+ try {
68
+ const parsed = new URL(url);
69
+ if (parsed.protocol === "http:" || parsed.protocol === "https:") return [];
70
+ } catch (error) {
71
+ return [createInvalidGrafanaUrlIssue(readUrlParseFailureKind(error))];
72
+ }
73
+
74
+ return [createInvalidGrafanaUrlIssue()];
75
+ }
76
+
77
+ function createInvalidGrafanaUrlIssue(failureKind?: string): GrafanaQueryReadinessIssue {
78
+ const failureDetail = failureKind ? ` URL parser failed with ${failureKind}.` : "";
79
+ return createGrafanaReadinessIssue(
80
+ "invalid_grafana_url",
81
+ "query.grafana.url",
82
+ `query.grafana.url must be a valid http:// or https:// URL.${failureDetail}`,
83
+ );
84
+ }
85
+
86
+ function readUrlParseFailureKind(error: unknown): string {
87
+ if (error instanceof Error) return error.name || "Error";
88
+ return typeof error;
89
+ }
90
+
91
+ function validateGrafanaAuth(config: ObservMeConfig): GrafanaQueryReadinessIssue[] {
92
+ const token = config.query.grafana.token.trim();
93
+ const username = config.query.grafana.username.trim();
94
+ const password = config.query.grafana.password.trim();
95
+
96
+ if (normalizeConfiguredGrafanaSecret(token)) return [];
97
+ if (normalizeConfiguredGrafanaSecret(username) && normalizeConfiguredGrafanaSecret(password)) return [];
98
+ if (hasUnresolvedEnvironmentPlaceholder(token)) return [createUnresolvedGrafanaTokenIssue()];
99
+ if (hasUnresolvedEnvironmentPlaceholder(username) || hasUnresolvedEnvironmentPlaceholder(password)) {
100
+ return [createUnresolvedGrafanaBasicAuthIssue()];
101
+ }
102
+ if (username || password) return [createIncompleteGrafanaBasicAuthIssue()];
103
+ return [createMissingGrafanaAuthIssue()];
104
+ }
105
+
106
+ function createUnresolvedGrafanaTokenIssue(): GrafanaQueryReadinessIssue {
107
+ return createGrafanaReadinessIssue(
108
+ "unresolved_grafana_token",
109
+ "query.grafana.token",
110
+ "query.grafana.token is unresolved. Set the referenced environment variable or configure query.grafana.username/password.",
111
+ );
112
+ }
113
+
114
+ function createUnresolvedGrafanaBasicAuthIssue(): GrafanaQueryReadinessIssue {
115
+ return createGrafanaReadinessIssue(
116
+ "unresolved_grafana_basic_auth",
117
+ "query.grafana.username/password",
118
+ "query.grafana.username/password contains an unresolved environment placeholder. Set the referenced environment variables or configure query.grafana.token.",
119
+ );
120
+ }
121
+
122
+ function createIncompleteGrafanaBasicAuthIssue(): GrafanaQueryReadinessIssue {
123
+ return createGrafanaReadinessIssue(
124
+ "incomplete_grafana_basic_auth",
125
+ "query.grafana.username/password",
126
+ "query.grafana.username/password is incomplete. Configure both values or configure query.grafana.token.",
127
+ );
128
+ }
129
+
130
+ function createMissingGrafanaAuthIssue(): GrafanaQueryReadinessIssue {
131
+ return createGrafanaReadinessIssue(
132
+ "missing_grafana_auth",
133
+ "query.grafana.token",
134
+ "query.grafana.token is missing and query.grafana.username/password are not configured.",
135
+ );
136
+ }
137
+
138
+ function validateGrafanaDatasourceUid(
139
+ config: ObservMeConfig,
140
+ datasourceKey: GrafanaQueryDatasourceKey | undefined,
141
+ ): GrafanaQueryReadinessIssue[] {
142
+ if (!datasourceKey) return [];
143
+
144
+ const key = `query.grafana.datasourceUids.${datasourceKey}`;
145
+ const uid = config.query.grafana.datasourceUids[datasourceKey].trim();
146
+ if (!uid) return [createGrafanaReadinessIssue("missing_grafana_datasource_uid", key, `${key} is not configured.`)];
147
+ if (!hasUnresolvedEnvironmentPlaceholder(uid)) return [];
148
+
149
+ return [
150
+ createGrafanaReadinessIssue(
151
+ "unresolved_grafana_datasource_uid",
152
+ key,
153
+ `${key} is unresolved. Set the referenced environment variable before running Grafana-backed queries.`,
154
+ ),
155
+ ];
156
+ }
157
+
158
+ function formatGrafanaQueryReadinessIssue(issue: GrafanaQueryReadinessIssue): string {
159
+ return issue.message;
160
+ }
161
+
162
+ function createGrafanaReadinessIssue(code: string, key: string, message: string): GrafanaQueryReadinessIssue {
163
+ return { code, key, message };
164
+ }