@spotify/backstage-plugin-soundcheck-metrics-backend 0.2.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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,15 @@
1
+ # @spotify/backstage-plugin-soundcheck-metrics-backend
2
+
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Soundcheck - Initial commit for the soundcheck-metrics-backend module.
8
+
9
+ ### Patch Changes
10
+
11
+ - Updated dependency `backstage` to `1.48.0`.
12
+ - Updated dependencies
13
+ - Updated dependencies
14
+ - Updated dependencies
15
+ - @spotify/backstage-plugin-soundcheck-node@0.11.0
package/README.md ADDED
@@ -0,0 +1,658 @@
1
+ # Soundcheck Metrics Backend
2
+
3
+ A Backstage backend module that exports Soundcheck usage metrics to BigQuery for analytics and monitoring.
4
+
5
+ ## Overview
6
+
7
+ This module extends the Soundcheck plugin to automatically collect and export aggregate metrics on a configurable schedule. It provides visibility into Soundcheck adoption, check pass rates, track completion, and group-level performance across your Backstage environments.
8
+
9
+ **Key Capabilities:**
10
+
11
+ - Scheduled metrics collection and export to BigQuery
12
+ - Group-based metric aggregation using Soundcheck's GroupHierarchy
13
+ - Track-level pass/fail metrics (entities completing entire tracks)
14
+ - Environment context tracking (installation ID, customer detection)
15
+ - Robust error handling with per-row retry logic
16
+
17
+ ## Architecture
18
+
19
+ This is a **backend module** (not a standalone plugin) that extends the Soundcheck plugin:
20
+
21
+ - Module ID: `soundcheck.metrics`
22
+ - Shares the Soundcheck database (`backstage_plugin_soundcheck`)
23
+ - Integrates with Soundcheck's `GroupHierarchyService`
24
+ - Exports data to Google BigQuery via the `@google-cloud/bigquery` library
25
+
26
+ ## Installation
27
+
28
+ ### 1. Install the Package
29
+
30
+ ```bash
31
+ cd packages/backend
32
+ yarn add @spotify/backstage-plugin-soundcheck-metrics-backend
33
+ ```
34
+
35
+ ### 2. Register the Module
36
+
37
+ Add the module to your backend in `packages/backend/src/index.ts`:
38
+
39
+ ```typescript
40
+ import { soundcheckMetricsModule } from '@spotify/backstage-plugin-soundcheck-metrics-backend';
41
+
42
+ // Register with your backend
43
+ backend.add(soundcheckMetricsModule);
44
+ ```
45
+
46
+ **Important:** The Soundcheck plugin must be installed and registered before this module.
47
+
48
+ ### 3. Configure the Module
49
+
50
+ Add configuration to `app-config.yaml`:
51
+
52
+ ```yaml
53
+ soundcheckMetrics:
54
+ enabled: true
55
+ schedule: '0 2 * * *' # Daily at 2 AM UTC
56
+
57
+ # Group types to aggregate metrics for
58
+ groupTypesToAggregate:
59
+ - squad
60
+ - misison
61
+ - product area
62
+ - studio
63
+
64
+ # BigQuery configuration
65
+ bigquery:
66
+ project: my-gcp-project
67
+ dataset: backstage_metrics
68
+ table: soundcheck_usage
69
+ credentials: ${BIGQUERY_CREDENTIALS} # Optional: use ADC if omitted
70
+ ```
71
+
72
+ ## Configuration Reference
73
+
74
+ ### Core Settings
75
+
76
+ | Field | Type | Required | Default | Description |
77
+ | ----------------------- | -------- | -------- | ------------- | ---------------------------------------------- |
78
+ | `enabled` | boolean | Yes | - | Enable/disable metrics export |
79
+ | `schedule` | string | No | `'0 2 * * *'` | Cron expression for export schedule |
80
+ | `groupTypesToAggregate` | string[] | No | `[]` | Group types to create separate metric rows for |
81
+
82
+ ### BigQuery Settings
83
+
84
+ | Field | Type | Required | Description |
85
+ | ---------------------- | ------ | -------- | ------------------------------------------------------------------ |
86
+ | `bigquery.project` | string | Yes | GCP project ID containing the BigQuery dataset |
87
+ | `bigquery.dataset` | string | Yes | BigQuery dataset name |
88
+ | `bigquery.table` | string | Yes | BigQuery table name |
89
+ | `bigquery.credentials` | string | No | Service account JSON (omit to use Application Default Credentials) |
90
+
91
+ ### Schedule Format
92
+
93
+ The `schedule` field uses standard cron format: `minute hour day month weekday`
94
+
95
+ **Examples:**
96
+
97
+ - `'0 2 * * *'` - Daily at 2 AM UTC (default)
98
+ - `'0 */4 * * *'` - Every 4 hours
99
+ - `'*/15 * * * *'` - Every 15 minutes
100
+ - `'0 0 * * 0'` - Weekly on Sunday at midnight
101
+ - `'0 0 1 * *'` - Monthly on the 1st at midnight
102
+
103
+ **Task Timeout:** The export task has a 10-minute timeout. If collection or export exceeds this limit, the task will be terminated and retried on the next scheduled run.
104
+
105
+ ### Group-Based Aggregation
106
+
107
+ Configure `groupTypesToAggregate` to export separate metric rows for each group of specified types:
108
+
109
+ ```yaml
110
+ soundcheckMetrics:
111
+ groupTypesToAggregate:
112
+ - squad # Metrics for each squad
113
+ - mission # Metrics for each mission
114
+ - studio # Metrics for each studio
115
+ ```
116
+
117
+ **How it works:**
118
+
119
+ 1. Queries `GroupHierarchyService` for all root groups of the specified types
120
+ 2. For each group, collects metrics filtered to entities owned by that group (including descendant groups)
121
+ 3. Exports a separate BigQuery row per group with:
122
+ - `group_name`: Name extracted from the group entity ref (e.g., "toast-infra")
123
+ - `group_level`: Group type (e.g., "squad", "mission")
124
+ - Check and campaign counts filtered to entities owned by the group
125
+ - Global counts for total campaigns, tracks, and checkers (not filtered by group)
126
+
127
+ **If omitted:** No metrics will be exported (the module requires at least one group type to be configured).
128
+
129
+ ### Authentication
130
+
131
+ Three methods for BigQuery authentication:
132
+
133
+ #### Option 1: Application Default Credentials (Recommended for GCP)
134
+
135
+ When running in GCP (Cloud Run, GKE), omit the `credentials` field:
136
+
137
+ ```yaml
138
+ soundcheckMetrics:
139
+ bigquery:
140
+ project: my-project
141
+ dataset: metrics
142
+ table: soundcheck_usage
143
+ ```
144
+
145
+ The module will automatically use the service account attached to the workload.
146
+
147
+ #### Option 2: Service Account JSON (Recommended for Non-GCP)
148
+
149
+ Provide service account credentials as a JSON string:
150
+
151
+ ```yaml
152
+ soundcheckMetrics:
153
+ bigquery:
154
+ credentials: ${BIGQUERY_CREDENTIALS}
155
+ ```
156
+
157
+ Set the environment variable:
158
+
159
+ ```bash
160
+ export BIGQUERY_CREDENTIALS='{"type":"service_account","project_id":"...","private_key":"..."}'
161
+ ```
162
+
163
+ #### Option 3: Credentials File (Development Only)
164
+
165
+ Reference a local credentials file:
166
+
167
+ ```yaml
168
+ soundcheckMetrics:
169
+ bigquery:
170
+ credentials: '${file:./credentials.json}'
171
+ ```
172
+
173
+ ## BigQuery Setup
174
+
175
+ ### 1. Create the Table
176
+
177
+ Run this SQL in the BigQuery Console or via `bq` CLI:
178
+
179
+ ```sql
180
+ CREATE TABLE `my-project.backstage_metrics.soundcheck_usage` (
181
+ -- Environment identification
182
+ environment_name STRING,
183
+ installation_id STRING,
184
+ organization_name STRING,
185
+ customer BOOL,
186
+
187
+ -- Group identification
188
+ group_name STRING,
189
+ group_level STRING,
190
+
191
+ -- Global counts (not filtered by group)
192
+ total_campaigns INT64,
193
+ total_tracks INT64,
194
+ total_checkers INT64,
195
+
196
+ -- Group-scoped metrics (filtered to entities owned by the group)
197
+ group_total_checks INT64,
198
+ group_passed_checks INT64,
199
+ group_failed_checks INT64,
200
+ group_active_campaigns INT64,
201
+ group_archived_campaigns INT64,
202
+ tracks_passed_by_entities_of_group INT64,
203
+ tracks_failed_by_entities_of_group INT64,
204
+
205
+ -- Timestamp
206
+ snapshot_timestamp TIMESTAMP
207
+ )
208
+ PARTITION BY DATE(snapshot_timestamp)
209
+ CLUSTER BY environment_name, customer, group_level
210
+ OPTIONS(
211
+ description="Soundcheck usage metrics exported from Backstage instances"
212
+ );
213
+ ```
214
+
215
+ #### Schema Details
216
+
217
+ **Environment Context:**
218
+
219
+ - `environment_name`: Hostname prefix from `app.baseUrl` (e.g., "spc-acme-prod")
220
+ - `installation_id`: Value from `K_SERVICE` environment variable (or 'UNKNOWN')
221
+ - `organization_name`: From `organization.name` config
222
+ - `customer`: `true` if environment name starts with `spc-` (Spotify customer pattern)
223
+
224
+ **Group Context:**
225
+
226
+ - `group_name`: Group entity name (e.g., "toast-infra", "platform")
227
+ - `group_level`: Group type (e.g., "squad", "mission", "studio")
228
+
229
+ **Global Metrics (not filtered by group):**
230
+
231
+ - `total_campaigns`: Total campaigns in the Soundcheck database
232
+ - `total_tracks`: Total tracks/programs in the database
233
+ - `total_checkers`: Total checker definitions in the database
234
+
235
+ **Group-Scoped Metrics (filtered to entities owned by the group):**
236
+
237
+ - `group_total_checks`: Total check results for entities owned by this group
238
+ - `group_passed_checks`: Passed check results for group entities
239
+ - `group_failed_checks`: Failed check results for group entities
240
+ - `group_active_campaigns`: Non-archived campaigns for group entities
241
+ - `group_archived_campaigns`: Archived campaigns for group entities
242
+ - `tracks_passed_by_entities_of_group`: Track instances where a group entity passed all checks
243
+ - `tracks_failed_by_entities_of_group`: Track instances where a group entity failed any check
244
+
245
+ **Timestamp:**
246
+
247
+ - `snapshot_timestamp`: UTC timestamp when metrics were collected (truncated to date boundary)
248
+
249
+ #### Partitioning & Clustering
250
+
251
+ - **Partitioned by date:** Reduces query costs by scanning only relevant dates
252
+ - **Clustered by:** `environment_name`, `customer`, `group_level` for optimal query performance
253
+
254
+ ### 2. Create a Service Account
255
+
256
+ ```bash
257
+ # Create service account
258
+ gcloud iam service-accounts create soundcheck-metrics-exporter \
259
+ --display-name="Soundcheck Metrics Exporter" \
260
+ --project=my-project
261
+
262
+ # Grant BigQuery Data Editor role
263
+ gcloud projects add-iam-policy-binding my-project \
264
+ --member="serviceAccount:soundcheck-metrics-exporter@my-project.iam.gserviceaccount.com" \
265
+ --role="roles/bigquery.dataEditor"
266
+
267
+ # Create key (for non-GCP environments)
268
+ gcloud iam service-accounts keys create credentials.json \
269
+ --iam-account=soundcheck-metrics-exporter@my-project.iam.gserviceaccount.com
270
+ ```
271
+
272
+ **Required Permissions:**
273
+
274
+ - `bigquery.tables.updateData` (insert rows)
275
+ - `bigquery.tables.get` (verify table exists)
276
+
277
+ The `roles/bigquery.dataEditor` role includes both permissions.
278
+
279
+ ### 3. Test the Configuration
280
+
281
+ Restart your Backstage backend and check logs for:
282
+
283
+ ```
284
+ [SoundcheckMetricsBackend] - BigQuery exporter initialized { project: 'my-project', dataset: 'backstage_metrics', table: 'soundcheck_usage' }
285
+ [SoundcheckMetricsBackend] - Soundcheck metrics export module initialized! { schedule: '0 2 * * *' }
286
+ ```
287
+
288
+ ## Metrics Reference
289
+
290
+ ### Track-Level Metrics
291
+
292
+ Track metrics measure how many track instances (entity + track combinations) resulted in all checks passing or any check failing.
293
+
294
+ **Calculation Logic:**
295
+
296
+ For each track in `soundcheck_program`:
297
+
298
+ 1. Get all check IDs associated with the track from `soundcheck_program_check_description`
299
+ 2. For each entity owned by the group:
300
+ - Query `check_result` for results matching the entity and track's check IDs
301
+ - **Passed:** Entity has results for ALL checks in the track, and ALL results are `state='passed'`
302
+ - **Failed:** Entity has at least one result with `state='failed'` for the track's checks
303
+
304
+ **Example:**
305
+
306
+ Track "Security Baseline" has 3 checks: `tls-check`, `vuln-scan`, `secrets-scan`
307
+
308
+ | Entity | TLS Check | Vuln Scan | Secrets Scan | Result |
309
+ | --------- | --------- | ----------- | ------------ | ---------------------------- |
310
+ | service-a | passed | passed | passed | **Track passed** (1) |
311
+ | service-b | passed | failed | passed | **Track failed** (1) |
312
+ | service-c | passed | (no result) | passed | **Not counted** (incomplete) |
313
+
314
+ Result: `tracks_passed_by_entities_of_group = 1`, `tracks_failed_by_entities_of_group = 1`
315
+
316
+ ### Metrics Summary Table
317
+
318
+ | Metric | Scope | Source | Description |
319
+ | ------------------------------------ | ------ | ----------------------------------------------------------------------- | ---------------------------------------------- |
320
+ | `total_campaigns` | Global | `soundcheck_campaign` | Total campaigns across all entities |
321
+ | `total_tracks` | Global | `soundcheck_program` | Total tracks/programs defined |
322
+ | `total_checkers` | Global | `checker` | Total checker definitions |
323
+ | `group_total_checks` | Group | `check_result` filtered by group entities | Total check results for group entities |
324
+ | `group_passed_checks` | Group | `check_result` WHERE `state='passed'` | Passed checks for group entities |
325
+ | `group_failed_checks` | Group | `check_result` WHERE `state='failed'` | Failed checks for group entities |
326
+ | `group_active_campaigns` | Group | `soundcheck_campaign` WHERE `archived=false` | Active campaigns for group entities |
327
+ | `group_archived_campaigns` | Group | `soundcheck_campaign` WHERE `archived=true` | Archived campaigns for group entities |
328
+ | `tracks_passed_by_entities_of_group` | Group | Calculated from `check_result` + `soundcheck_program_check_description` | Track instances fully passed by group entities |
329
+ | `tracks_failed_by_entities_of_group` | Group | Calculated from `check_result` + `soundcheck_program_check_description` | Track instances failed by group entities |
330
+
331
+ ## Example Queries
332
+
333
+ ### Latest Metrics Per Environment
334
+
335
+ ```sql
336
+ SELECT
337
+ environment_name,
338
+ group_name,
339
+ group_level,
340
+ group_total_checks,
341
+ group_passed_checks,
342
+ group_failed_checks,
343
+ ROUND(SAFE_DIVIDE(group_passed_checks, group_total_checks) * 100, 2) AS pass_rate_pct,
344
+ tracks_passed_by_entities_of_group,
345
+ tracks_failed_by_entities_of_group,
346
+ snapshot_timestamp
347
+ FROM `my-project.backstage_metrics.soundcheck_usage`
348
+ WHERE DATE(snapshot_timestamp) = CURRENT_DATE()
349
+ ORDER BY environment_name, group_name;
350
+ ```
351
+
352
+ ### Pass Rate Trend Over Time
353
+
354
+ ```sql
355
+ SELECT
356
+ DATE(snapshot_timestamp) AS date,
357
+ environment_name,
358
+ group_name,
359
+ SUM(group_passed_checks) AS total_passed,
360
+ SUM(group_failed_checks) AS total_failed,
361
+ ROUND(SAFE_DIVIDE(SUM(group_passed_checks), SUM(group_total_checks)) * 100, 2) AS pass_rate_pct
362
+ FROM `my-project.backstage_metrics.soundcheck_usage`
363
+ WHERE snapshot_timestamp >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 30 DAY)
364
+ GROUP BY date, environment_name, group_name
365
+ ORDER BY date DESC, environment_name, group_name;
366
+ ```
367
+
368
+ ### Customer vs Internal Comparison
369
+
370
+ ```sql
371
+ SELECT
372
+ customer,
373
+ COUNT(DISTINCT environment_name) AS num_environments,
374
+ COUNT(DISTINCT group_name) AS num_groups,
375
+ AVG(group_total_checks) AS avg_checks_per_group,
376
+ AVG(SAFE_DIVIDE(group_passed_checks, group_total_checks)) AS avg_pass_rate,
377
+ SUM(tracks_passed_by_entities_of_group) AS total_tracks_passed,
378
+ SUM(tracks_failed_by_entities_of_group) AS total_tracks_failed
379
+ FROM `my-project.backstage_metrics.soundcheck_usage`
380
+ WHERE DATE(snapshot_timestamp) = CURRENT_DATE()
381
+ GROUP BY customer;
382
+ ```
383
+
384
+ ### Top Performing Groups
385
+
386
+ ```sql
387
+ SELECT
388
+ group_level,
389
+ group_name,
390
+ environment_name,
391
+ group_total_checks,
392
+ group_passed_checks,
393
+ ROUND(SAFE_DIVIDE(group_passed_checks, group_total_checks) * 100, 2) AS pass_rate_pct,
394
+ tracks_passed_by_entities_of_group,
395
+ tracks_failed_by_entities_of_group
396
+ FROM `my-project.backstage_metrics.soundcheck_usage`
397
+ WHERE DATE(snapshot_timestamp) = CURRENT_DATE()
398
+ AND group_total_checks > 0
399
+ ORDER BY pass_rate_pct DESC
400
+ LIMIT 20;
401
+ ```
402
+
403
+ ### Track Completion Rates
404
+
405
+ ```sql
406
+ SELECT
407
+ group_level,
408
+ group_name,
409
+ tracks_passed_by_entities_of_group,
410
+ tracks_failed_by_entities_of_group,
411
+ tracks_passed_by_entities_of_group + tracks_failed_by_entities_of_group AS total_track_instances,
412
+ ROUND(
413
+ SAFE_DIVIDE(
414
+ tracks_passed_by_entities_of_group,
415
+ tracks_passed_by_entities_of_group + tracks_failed_by_entities_of_group
416
+ ) * 100,
417
+ 2
418
+ ) AS track_completion_rate_pct
419
+ FROM `my-project.backstage_metrics.soundcheck_usage`
420
+ WHERE DATE(snapshot_timestamp) = CURRENT_DATE()
421
+ AND (tracks_passed_by_entities_of_group + tracks_failed_by_entities_of_group) > 0
422
+ ORDER BY track_completion_rate_pct DESC;
423
+ ```
424
+
425
+ ## Monitoring & Logs
426
+
427
+ All log messages are prefixed with `[SoundcheckMetricsBackend] - ` for easy filtering.
428
+
429
+ ### Successful Export
430
+
431
+ ```
432
+ [SoundcheckMetricsBackend] - Soundcheck metrics export module initialized! { schedule: '0 2 * * *' }
433
+ [SoundcheckMetricsBackend] - Starting scheduled Soundcheck metrics export
434
+ [SoundcheckMetricsBackend] - Collecting Soundcheck metrics...
435
+ [SoundcheckMetricsBackend] - Environment context retrieved { environmentName: 'spc-acme-prod', ... }
436
+ [SoundcheckMetricsBackend] - Collecting metrics for group types: squad, product area, studio, mission
437
+ [SoundcheckMetricsBackend] - Collecting metrics for group: platform-squad (type: squad)
438
+ [SoundcheckMetricsBackend] - Exporting metrics to BigQuery { environment: 'spc-acme-prod', ... }
439
+ [SoundcheckMetricsBackend] - Successfully exported metrics to BigQuery
440
+ [SoundcheckMetricsBackend] - Metrics collected successfully for 12 groups
441
+ [SoundcheckMetricsBackend] - Completed scheduled Soundcheck metrics export: 12 succeeded, 0 failed
442
+ ```
443
+
444
+ ### Common Log Patterns
445
+
446
+ **Module initialization:**
447
+
448
+ ```
449
+ [SoundcheckMetricsBackend] - BigQuery exporter initialized { project: '...', dataset: '...', table: '...' }
450
+ [SoundcheckMetricsBackend] - Soundcheck metrics export module initialized! { schedule: '...' }
451
+ ```
452
+
453
+ **Per-group collection:**
454
+
455
+ ```
456
+ [SoundcheckMetricsBackend] - Collecting metrics for group: toast-infra (type: squad)
457
+ [SoundcheckMetricsBackend] - Found 47 entities owned by toast-infra
458
+ ```
459
+
460
+ **Export summary:**
461
+
462
+ ```
463
+ [SoundcheckMetricsBackend] - Exporting 15 metrics row(s) to BigQuery
464
+ [SoundcheckMetricsBackend] - Completed scheduled Soundcheck metrics export: 15 succeeded, 0 failed
465
+ ```
466
+
467
+ ## Troubleshooting
468
+
469
+ ### Module Not Initializing
470
+
471
+ **Symptom:** No initialization logs appear
472
+
473
+ **Possible Causes:**
474
+
475
+ 1. `soundcheckMetrics.enabled` is not set to `true`
476
+ 2. Configuration is missing or invalid
477
+ 3. Module not registered in `packages/backend/src/index.ts`
478
+
479
+ **Check:**
480
+
481
+ ```
482
+ [SoundcheckMetricsBackend] - Soundcheck metrics export is disabled.
483
+ ```
484
+
485
+ **Solution:** Verify config and ensure `enabled: true`
486
+
487
+ ### No Metrics Exported
488
+
489
+ **Symptom:** Export completes but 0 rows inserted
490
+
491
+ **Possible Causes:**
492
+
493
+ 1. `groupTypesToAggregate` is empty or contains invalid group types
494
+ 2. No groups exist for the specified types
495
+ 3. All groups failed to collect metrics
496
+
497
+ **Check logs for:**
498
+
499
+ ```
500
+ [SoundcheckMetricsBackend] - No groups found for types: squad, product area, studio, mission
501
+ ```
502
+
503
+ **Solution:**
504
+
505
+ - Verify `groupTypesToAggregate` contains valid group types
506
+ - Confirm groups exist in Soundcheck's GroupHierarchy
507
+ - Check that GroupHierarchyService is properly configured
508
+
509
+ ### BigQuery Insert Failures
510
+
511
+ **Symptom:** Errors during export
512
+
513
+ **Error:** `no such field: tracks_passed_by_entities_of_group`
514
+
515
+ **Solution:** The BigQuery table schema doesn't match the exported data structure. Run the `ALTER TABLE` commands:
516
+
517
+ ```sql
518
+ ALTER TABLE `my-project.backstage_metrics.soundcheck_usage`
519
+ ADD COLUMN tracks_passed_by_entities_of_group INT64;
520
+
521
+ ALTER TABLE `my-project.backstage_metrics.soundcheck_usage`
522
+ ADD COLUMN tracks_failed_by_entities_of_group INT64;
523
+ ```
524
+
525
+ **Error:** `Not found: Table my-project:backstage_metrics.soundcheck_usage`
526
+
527
+ **Solution:** Create the BigQuery table using the SQL in the "Create the Table" section.
528
+
529
+ **Error:** `Permission 'bigquery.tables.updateData' denied`
530
+
531
+ **Solution:** Grant the service account the BigQuery Data Editor role:
532
+
533
+ ```bash
534
+ gcloud projects add-iam-policy-binding my-project \
535
+ --member="serviceAccount:soundcheck-metrics-exporter@my-project.iam.gserviceaccount.com" \
536
+ --role="roles/bigquery.dataEditor"
537
+ ```
538
+
539
+ ### Missing Soundcheck Tables
540
+
541
+ **Symptom:** Warnings about table queries failing
542
+
543
+ **Logs:**
544
+
545
+ ```
546
+ [SoundcheckMetricsBackend] - Failed to query table soundcheck_campaign
547
+ [SoundcheckMetricsBackend] - Failed to load track-check mappings
548
+ ```
549
+
550
+ **Behavior:** The module uses defensive error handling. Missing tables result in 0 values for the corresponding metrics, but the export continues.
551
+
552
+ **Solution:**
553
+
554
+ - Verify Soundcheck plugin is installed and initialized
555
+ - Check that Soundcheck has created its database tables
556
+ - This is expected if Soundcheck is not fully configured in the environment
557
+
558
+ ### Authentication Errors
559
+
560
+ **Error:** `Could not load the default credentials`
561
+
562
+ **Solution:** Provide credentials via one of three methods:
563
+
564
+ 1. Set `soundcheckMetrics.bigquery.credentials` with service account JSON
565
+ 2. Use Application Default Credentials (when running in GCP)
566
+ 3. Set `GOOGLE_APPLICATION_CREDENTIALS` environment variable to a credentials file path
567
+
568
+ **Error:** `invalid_grant: Invalid JWT Signature`
569
+
570
+ **Solution:** The service account key may be corrupted or incorrectly formatted. Regenerate the key:
571
+
572
+ ```bash
573
+ gcloud iam service-accounts keys create new-credentials.json \
574
+ --iam-account=soundcheck-metrics-exporter@my-project.iam.gserviceaccount.com
575
+ ```
576
+
577
+ ### Partial Export Failures
578
+
579
+ **Symptom:** Some groups succeed, others fail
580
+
581
+ **Logs:**
582
+
583
+ ```
584
+ [SoundcheckMetricsBackend] - Failed to export metrics for group platform-squad { error: ... }
585
+ [SoundcheckMetricsBackend] - Completed scheduled Soundcheck metrics export: 10 succeeded, 2 failed
586
+ ```
587
+
588
+ **Behavior:** The module uses per-row error handling. Individual group failures don't prevent other groups from being exported.
589
+
590
+ **Solution:** Check the error details in the logs to diagnose the specific group failure (e.g., entity ownership query failure, BigQuery validation error).
591
+
592
+ ## Development
593
+
594
+ ### Running Locally
595
+
596
+ 1. Set up local BigQuery credentials:
597
+
598
+ ```bash
599
+ export BIGQUERY_CREDENTIALS=$(cat credentials.json)
600
+ ```
601
+
602
+ 2. Configure `app-config.local.yaml`:
603
+
604
+ ```yaml
605
+ soundcheckMetrics:
606
+ enabled: true
607
+ schedule: '*/5 * * * *' # Every 5 minutes for testing
608
+ groupTypesToAggregate:
609
+ - squad
610
+ bigquery:
611
+ project: my-dev-project
612
+ dataset: dev_metrics
613
+ table: soundcheck_usage
614
+ credentials: ${BIGQUERY_CREDENTIALS}
615
+ ```
616
+
617
+ 3. Start the backend:
618
+
619
+ ```bash
620
+ yarn workspace backend start
621
+ ```
622
+
623
+ ### Testing Metrics Collection
624
+
625
+ To test without waiting for the scheduled run, modify the schedule to run frequently:
626
+
627
+ ```yaml
628
+ schedule: '*/1 * * * *' # Every minute
629
+ ```
630
+
631
+ Or trigger manually by restarting the backend (the initial export runs immediately).
632
+
633
+ ### Debugging
634
+
635
+ Enable debug logs:
636
+
637
+ ```yaml
638
+ backend:
639
+ logger:
640
+ level: debug
641
+ ```
642
+
643
+ Filter for module logs:
644
+
645
+ ```bash
646
+ yarn workspace backend start | grep '\[SoundcheckMetricsBackend\]'
647
+ ```
648
+
649
+ ### Adding New Metrics
650
+
651
+ 1. Update the `SoundcheckMetrics` interface in `src/types.ts`
652
+ 2. Update the metric collection logic in `src/service/MetricsCollector.ts`
653
+ 3. Update the BigQuery table schema (ALTER TABLE or recreate)
654
+ 4. Update this README with the new metric documentation
655
+
656
+ ## License
657
+
658
+ Copyright © 2024 Spotify AB. All rights reserved.
package/config.d.ts ADDED
@@ -0,0 +1,58 @@
1
+ export interface Config {
2
+ /**
3
+ * Configuration for the Soundcheck Metrics backend module
4
+ */
5
+ soundcheckMetrics?: {
6
+ /**
7
+ * Enable or disable the metrics export
8
+ * @visibility backend
9
+ */
10
+ enabled?: boolean;
11
+
12
+ /**
13
+ * Cron schedule for metrics export (default: '0 2 * * *' - daily at 2 AM UTC)
14
+ * Format: minute hour day month weekday
15
+ * @visibility backend
16
+ */
17
+ schedule?: string;
18
+
19
+ /**
20
+ * Group types to aggregate metrics for
21
+ * When specified, exports separate metric rows for each group of these types
22
+ * Example: ['squad', 'product area', 'studio', 'mission']
23
+ * @visibility backend
24
+ */
25
+ groupTypesToAggregate?: string[];
26
+
27
+ /**
28
+ * BigQuery configuration
29
+ * @visibility backend
30
+ */
31
+ bigquery: {
32
+ /**
33
+ * GCP project ID
34
+ * @visibility backend
35
+ */
36
+ project: string;
37
+
38
+ /**
39
+ * BigQuery dataset ID
40
+ * @visibility backend
41
+ */
42
+ dataset: string;
43
+
44
+ /**
45
+ * BigQuery table ID
46
+ * @visibility backend
47
+ */
48
+ table: string;
49
+
50
+ /**
51
+ * Optional service account credentials as JSON string
52
+ * If not provided, will use Application Default Credentials
53
+ * @visibility secret
54
+ */
55
+ credentials?: string;
56
+ };
57
+ };
58
+ }
@@ -0,0 +1,2 @@
1
+ "use strict";Object.defineProperty(exports,"__esModule",{value:!0});var e=require("./plugin.cjs.js");exports.default=e.soundcheckMetricsModule;
2
+ //# sourceMappingURL=index.cjs.js.map
@@ -0,0 +1,90 @@
1
+ import * as _backstage_backend_plugin_api from '@backstage/backend-plugin-api';
2
+
3
+ /**
4
+ * Soundcheck Metrics Backend Module
5
+ *
6
+ * A backend module that extends the Soundcheck plugin to export aggregate metrics
7
+ * to BigQuery on a configurable schedule. This enables tracking of Soundcheck usage
8
+ * and performance across Portal instances.
9
+ *
10
+ * The module collects metrics such as:
11
+ * - Total checks, campaigns, programs, and checkers
12
+ * - Pass/fail rates for checks
13
+ * - Environment identification and customer flags
14
+ *
15
+ * Configuration is provided via app-config.yaml under the 'soundcheckMetrics' key.
16
+ *
17
+ * @public
18
+ */
19
+ declare const soundcheckMetricsModule: _backstage_backend_plugin_api.BackendFeature;
20
+
21
+ /**
22
+ * Soundcheck metrics data structure for BigQuery export
23
+ *
24
+ * Represents a single snapshot of Soundcheck usage metrics at a point in time.
25
+ * This structure maps directly to the BigQuery table schema.
26
+ *
27
+ * @public
28
+ */
29
+ interface SoundcheckMetrics {
30
+ /** Environment identifier extracted from app.baseUrl hostname */
31
+ environment_name: string;
32
+ /** Unique installation ID from K_SERVICE environment variable (or 'UNKNOWN' as fallback) */
33
+ installation_id: string;
34
+ /** Organization name from organization.name config */
35
+ organization_name: string;
36
+ /** Whether this is a Spotify customer environment (identified by spc-* naming pattern) */
37
+ customer: boolean;
38
+ /** Total number of campaigns in the soundcheck_campaign table (global, not filtered by group) */
39
+ total_campaigns: number;
40
+ /** Total number of tracks in the soundcheck_program table (global, not filtered by group) */
41
+ total_tracks: number;
42
+ /** Total number of checker definitions in the checker table (global, not filtered by group) */
43
+ total_checkers: number;
44
+ /** The name of the group for which metrics are being collected (e.g., 'toast-infra', 'PDX', etc.) */
45
+ group_name: string;
46
+ /** Group level classification (e.g., 'squad', 'product area', 'studio', 'mission') */
47
+ group_level: string;
48
+ /** Total number of check results for entities owned by this group */
49
+ group_total_checks: number;
50
+ /** Number of passed check results for entities owned by this group */
51
+ group_passed_checks: number;
52
+ /** Number of failed check results for entities owned by this group */
53
+ group_failed_checks: number;
54
+ /** Number of active (non-archived) campaigns for entities owned by this group */
55
+ group_active_campaigns: number;
56
+ /** Number of archived campaigns for entities owned by this group */
57
+ group_archived_campaigns: number;
58
+ /** Number of track instances where an entity owned by this group passed all checks in the track */
59
+ tracks_passed_by_entities_of_group: number;
60
+ /** Number of track instances where an entity owned by this group failed any check in the track */
61
+ tracks_failed_by_entities_of_group: number;
62
+ /** Number of campaign instances where an entity owned by this group passed all checks in the campaign */
63
+ campaigns_passed_by_entities_of_group: number;
64
+ /** Number of campaign instances where an entity owned by this group failed any check in the campaign */
65
+ campaigns_failed_by_entities_of_group: number;
66
+ /** Timestamp when this metrics snapshot was collected */
67
+ snapshot_timestamp: Date;
68
+ }
69
+ /**
70
+ * Environment context information
71
+ *
72
+ * Metadata about the current Backstage instance, used to identify the source
73
+ * of exported metrics in BigQuery.
74
+ *
75
+ * @public
76
+ */
77
+ interface EnvironmentContext {
78
+ /** Environment name extracted from the first segment of app.baseUrl hostname */
79
+ environmentName: string;
80
+ /** Unique installation ID, or 'UNKNOWN' if unavailable */
81
+ installationId: string;
82
+ /** Organization name from organization.name config */
83
+ organizationName: string;
84
+ /** Whether this environment is identified as a customer Portal instance */
85
+ isCustomer: boolean;
86
+ /** Group types to aggregate metrics for (e.g., ['squad', 'product area']) */
87
+ groupTypesToAggregate: string[];
88
+ }
89
+
90
+ export { type EnvironmentContext, type SoundcheckMetrics, soundcheckMetricsModule as default };
@@ -0,0 +1,2 @@
1
+ "use strict";var c=require("@backstage/backend-plugin-api"),m=require("@spotify/backstage-plugin-soundcheck-node"),M=require("./service/BigQueryExporter.cjs.js"),f=require("./service/EnvironmentContextService.cjs.js"),v=require("./service/MetricsCollector.cjs.js");const B=c.createBackendModule({pluginId:"soundcheck",moduleId:"metrics",register(s){s.registerInit({deps:{config:c.coreServices.rootConfig,logger:c.coreServices.logger,database:c.coreServices.database,scheduler:c.coreServices.scheduler,soundcheckBackendClient:m.soundcheckBackendClientServiceRef},async init({config:r,logger:e,database:u,scheduler:a,soundcheckBackendClient:l}){if(!r.getOptionalBoolean("soundcheckMetrics.enabled")){e.info("[SoundcheckMetricsBackend] - Soundcheck metrics export is disabled.");return}e.info("[SoundcheckMetricsBackend] - Initializing Soundcheck metrics export module...");const k=await u.getClient(),h=new f.EnvironmentContextService(r,e),g=new v.MetricsCollectorService(k,h,e,l),S=new M.BigQueryExporterService(r,e),t=r.getOptionalString("soundcheckMetrics.schedule")||"0 2 * * *";await a.scheduleTask({id:"soundcheck-metrics-export",frequency:{cron:t},timeout:{minutes:10},scope:"global",fn:async()=>{try{e.info("[SoundcheckMetricsBackend] - Starting scheduled Soundcheck metrics export");const o=await g.collectMetrics();e.info(`[SoundcheckMetricsBackend] - Exporting ${o.length} metrics row(s) to BigQuery`);let i=0,n=0;for(const d of o)try{await S.export(d),i++}catch(p){n++,e.error(`[SoundcheckMetricsBackend] - Failed to export metrics for group ${d.group_name}`,{error:p})}e.info(`[SoundcheckMetricsBackend] - Completed scheduled Soundcheck metrics export: ${i} succeeded, ${n} failed`)}catch(o){e.error("[SoundcheckMetricsBackend] - Error during scheduled metrics export",{error:o})}}}),e.info("[SoundcheckMetricsBackend] - Soundcheck metrics export module initialized!",{schedule:t})}})}});exports.soundcheckMetricsModule=B;
2
+ //# sourceMappingURL=plugin.cjs.js.map
@@ -0,0 +1,2 @@
1
+ "use strict";var c=require("@google-cloud/bigquery");class s{constructor(e,t){this.logger=t;const r=e.getString("soundcheckMetrics.bigquery.project");this.#e=e.getString("soundcheckMetrics.bigquery.dataset"),this.#t=e.getString("soundcheckMetrics.bigquery.table");const i=e.getOptionalString("soundcheckMetrics.bigquery.credentials");this.#r=new c.BigQuery({projectId:r,...i&&{credentials:JSON.parse(i)}}),this.logger.info("[SoundcheckMetricsBackend] - BigQuery exporter initialized",{project:r,dataset:this.#e,table:this.#t})}#r;#e;#t;async export(e){this.logger.info("[SoundcheckMetricsBackend] - Exporting metrics to BigQuery",{environment:e.environment_name,timestamp:e.snapshot_timestamp.toISOString()});try{await this.#r.dataset(this.#e).table(this.#t).insert([e]),this.logger.info("[SoundcheckMetricsBackend] - Successfully exported metrics to BigQuery")}catch(t){this.logger.error("[SoundcheckMetricsBackend] - Failed to export metrics to BigQuery",{error:t})}}}exports.BigQueryExporterService=s;
2
+ //# sourceMappingURL=BigQueryExporter.cjs.js.map
@@ -0,0 +1,2 @@
1
+ "use strict";class s{constructor(n,t){this.config=n,this.logger=t}async getContext(){const n=this.config.getString("app.baseUrl"),t=new URL(n).hostname,e=process.env.K_SERVICE??"Spotify-Internal",o=this.config.getOptionalString("organization.name")||"Unknown",i=e.startsWith("spc-"),r=this.config.getOptionalStringArray("soundcheckMetrics.groupTypesToAggregate")||[];return this.logger.info("[SoundcheckMetricsBackend] - Environment context retrieved",{hostname:t,installationId:e,organizationName:o,isCustomer:i,groupTypesToAggregate:r}),{environmentName:t,installationId:e,organizationName:o,isCustomer:i,groupTypesToAggregate:r}}}exports.EnvironmentContextService=s;
2
+ //# sourceMappingURL=EnvironmentContextService.cjs.js.map
@@ -0,0 +1,2 @@
1
+ "use strict";class k{constructor(e,i,t,c){this.database=e,this.environmentContextService=i,this.logger=t,this.soundcheckBackendClient=c}async#t(){try{const e=await this.soundcheckBackendClient.getTracks(),i=new Map;for(const t of e){const c=new Set;for(const r of t.levels)for(const s of r.checks)c.add(s.id);i.set(t.id,{checkIds:c,type:t.type})}return this.logger.debug(`[SoundcheckMetricsBackend] - Loaded ${i.size} tracks with check mappings and types`),i}catch(e){return this.logger.warn("[SoundcheckMetricsBackend] - Failed to load track information",{error:e}),new Map}}async#i(){try{return(await this.soundcheckBackendClient.getCheckIds()).length}catch(e){return this.logger.warn("[SoundcheckMetricsBackend] - Failed to get total checks",{error:e}),0}}async#c(e,i){if(!e||e.length===0)return{tracks_passed_by_entities:0,tracks_failed_by_entities:0,campaigns_passed_by_entities:0,campaigns_failed_by_entities:0};if(i.size===0)return{tracks_passed_by_entities:0,tracks_failed_by_entities:0,campaigns_passed_by_entities:0,campaigns_failed_by_entities:0};try{let t=0,c=0,r=0,s=0;for(const[n,a]of i.entries()){const o=Array.from(a.checkIds),g=a.type==="campaign";for(const h of e)try{const d=await this.database("check_result").where("entity_ref",h).whereIn("check_id",o).select("state");if(d.length===0)continue;const _=d.every(l=>l.state==="passed"),u=d.some(l=>l.state==="failed");_&&d.length===o.length?g?r++:t++:u&&(g?s++:c++)}catch(d){this.logger.debug(`[SoundcheckMetricsBackend] - Failed to get check results for entity ${h}`,{error:d})}}return{tracks_passed_by_entities:t,tracks_failed_by_entities:c,campaigns_passed_by_entities:r,campaigns_failed_by_entities:s}}catch(t){return this.logger.warn("[SoundcheckMetricsBackend] - Failed to calculate track metrics",{error:t}),{tracks_passed_by_entities:0,tracks_failed_by_entities:0,campaigns_passed_by_entities:0,campaigns_failed_by_entities:0}}}async#e(e,i,t){try{let c=this.database(e).count("* as count");if(i&&(c=c.where(i.field,i.value)),t!==void 0)if(t.length>0)c=c.whereIn("entity_ref",t);else return 0;const r=await c.first(),s=Number(r?.count||0);return this.logger.debug(`[SoundcheckMetricsBackend] - Queried ${e}: ${s} rows`,{whereClause:i,filteredByGroup:!!t}),s}catch(c){return this.logger.warn(`[SoundcheckMetricsBackend] - Failed to query table ${e}`,{error:c.message,code:c.code,table:e,where:i}),0}}async collectMetrics(){this.logger.info("[SoundcheckMetricsBackend] - Collecting Soundcheck metrics...");const e=await this.environmentContextService.getContext();if(e.groupTypesToAggregate.length===0)return this.logger.warn("[SoundcheckMetricsBackend] - No group types are configured, collecting global metrics..."),[];this.logger.info(`[SoundcheckMetricsBackend] - Collecting metrics for group types: ${e.groupTypesToAggregate.join(", ")}`);let i;try{i=await this.soundcheckBackendClient.getGroupsOfType(e.groupTypesToAggregate)}catch(n){return this.logger.error(`[SoundcheckMetricsBackend] - Failed to get groups of type [${e.groupTypesToAggregate.join(", ")}]`,n),[]}if(i.length===0)return this.logger.warn(`[SoundcheckMetricsBackend] - No groups found for types: ${e.groupTypesToAggregate.join(", ")}`),[];const t=await this.#t(),c=t.size,r=await this.#i(),s=[];for(const n of i){const a=n.entityRef.split("/"),o=a[a.length-1];this.logger.info(`[SoundcheckMetricsBackend] - Collecting metrics for group: ${o} (type: ${n.type})`);try{const g=await this.#s(e,n.entityRef,o,n.type,t,c,r);g?s.push(g):this.logger.warn(`[SoundcheckMetricsBackend] - Skipping metrics for group ${o} - collection returned no data`)}catch(g){this.logger.error(`[SoundcheckMetricsBackend] - Failed to collect metrics for group ${o}`,g)}}return this.logger.info(`[SoundcheckMetricsBackend] - Metrics collected successfully for ${s.length} groups`),s}async#s(e,i,t,c,r,s,n){let a;if(i)try{a=await this.soundcheckBackendClient.getOwnedEntityRefs(i,!0),this.logger.debug(`[SoundcheckMetricsBackend] - Found ${a?.length||0} entities owned by ${t}`)}catch(p){this.logger.warn(`[SoundcheckMetricsBackend] - Failed to get owned entities for group ${t}`,{error:p});return}const o=await this.#e("soundcheck_campaign"),g=await this.#e("check_result",void 0,a),h=await this.#e("soundcheck_campaign",{field:"archived",value:!1}),d=await this.#e("soundcheck_campaign",{field:"archived",value:!0}),_=await this.#e("check_result",{field:"state",value:"passed"},a),u=await this.#e("check_result",{field:"state",value:"failed"},a),l=await this.#c(a,r);return{environment_name:e.environmentName,installation_id:e.installationId,organization_name:e.organizationName,customer:e.isCustomer,total_campaigns:o,total_tracks:s,total_checkers:n,group_name:t,group_level:c,group_total_checks:g,group_passed_checks:_,group_failed_checks:u,group_active_campaigns:h,group_archived_campaigns:d,tracks_passed_by_entities_of_group:l.tracks_passed_by_entities,tracks_failed_by_entities_of_group:l.tracks_failed_by_entities,campaigns_passed_by_entities_of_group:l.campaigns_passed_by_entities,campaigns_failed_by_entities_of_group:l.campaigns_failed_by_entities,snapshot_timestamp:new Date(Date.UTC(new Date().getUTCFullYear(),new Date().getUTCMonth(),new Date().getUTCDate()))}}}exports.MetricsCollectorService=k;
2
+ //# sourceMappingURL=MetricsCollector.cjs.js.map
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@spotify/backstage-plugin-soundcheck-metrics-backend",
3
+ "description": "Backstage backend module for exporting Soundcheck metrics to BigQuery",
4
+ "version": "0.2.0",
5
+ "license": "SEE LICENSE IN LICENSE.md",
6
+ "homepage": "https://backstage.spotify.com",
7
+ "main": "dist/index.cjs.js",
8
+ "types": "dist/index.d.ts",
9
+ "publishConfig": {
10
+ "main": "dist/index.cjs.js",
11
+ "types": "dist/index.d.ts"
12
+ },
13
+ "backstage": {
14
+ "role": "backend-plugin-module",
15
+ "pluginId": "soundcheck",
16
+ "pluginPackage": "@spotify/backstage-plugin-soundcheck-backend",
17
+ "features": {
18
+ ".": "@backstage/BackendFeature"
19
+ }
20
+ },
21
+ "scripts": {
22
+ "build": "backstage-cli package build --minify",
23
+ "lint": "backstage-cli package lint --max-warnings 0",
24
+ "test": "backstage-cli package test",
25
+ "clean": "backstage-cli package clean",
26
+ "prepack": "backstage-cli package prepack",
27
+ "postpack": "backstage-cli package postpack"
28
+ },
29
+ "dependencies": {
30
+ "@backstage/backend-plugin-api": "^1.7.0",
31
+ "@backstage/catalog-client": "^1.13.0",
32
+ "@backstage/config": "^1.3.6",
33
+ "@backstage/plugin-catalog-node": "^2.0.0",
34
+ "@google-cloud/bigquery": "^8.0.0",
35
+ "@spotify/backstage-plugin-soundcheck-node": "^0.11.0",
36
+ "knex": "^3.0.0"
37
+ },
38
+ "devDependencies": {
39
+ "@backstage/backend-test-utils": "^1.11.0",
40
+ "@backstage/cli": "^0.35.4"
41
+ },
42
+ "files": [
43
+ "dist",
44
+ "!dist/**/*.map",
45
+ "config.d.ts"
46
+ ],
47
+ "configSchema": "config.d.ts",
48
+ "typesVersions": {
49
+ "*": {
50
+ "package.json": [
51
+ "package.json"
52
+ ]
53
+ }
54
+ }
55
+ }