github.com/grafana/synthetic-monitoring-agent@v0.64.2/internal/telemetry/region_pusher_test.go (about) 1 package telemetry 2 3 import ( 4 "context" 5 "errors" 6 "reflect" 7 "sync" 8 "testing" 9 "testing/synctest" 10 "time" 11 12 "github.com/grafana/synthetic-monitoring-agent/internal/testhelper" 13 sm "github.com/grafana/synthetic-monitoring-agent/pkg/pb/synthetic_monitoring" 14 15 prom "github.com/prometheus/client_golang/prometheus" 16 prommodel "github.com/prometheus/client_model/go" 17 "github.com/stretchr/testify/require" 18 "google.golang.org/grpc" 19 ) 20 21 const ( 22 instance = "instance" 23 regionID = 1 24 ) 25 26 type testData struct { 27 executions []Execution 28 message sm.RegionTelemetry 29 } 30 31 func getTestDataset(idx int) testData { 32 data := []testData{ 33 { 34 executions: []Execution{ 35 { 36 LocalTenantID: 1, 37 CheckClass: sm.CheckClass_PROTOCOL, 38 Duration: 59 * time.Second, 39 CostAttributionLabels: []sm.CostAttributionLabel{}, 40 }, 41 { 42 LocalTenantID: 1, 43 CheckClass: sm.CheckClass_PROTOCOL, 44 Duration: 60 * time.Second, 45 46 CostAttributionLabels: []sm.CostAttributionLabel{}, 47 }, 48 { 49 LocalTenantID: 2, 50 CheckClass: sm.CheckClass_SCRIPTED, 51 Duration: 61 * time.Second, 52 53 CostAttributionLabels: []sm.CostAttributionLabel{}, 54 }, 55 { 56 LocalTenantID: 2, 57 CheckClass: sm.CheckClass_SCRIPTED, 58 Duration: 30 * time.Second, 59 60 CostAttributionLabels: []sm.CostAttributionLabel{}, 61 }, 62 { 63 LocalTenantID: 3, 64 CheckClass: sm.CheckClass_BROWSER, 65 Duration: 61 * time.Second, 66 67 CostAttributionLabels: []sm.CostAttributionLabel{}, 68 }, 69 { 70 LocalTenantID: 3, 71 CheckClass: sm.CheckClass_BROWSER, 72 Duration: 30 * time.Second, 73 74 CostAttributionLabels: []sm.CostAttributionLabel{}, 75 }, 76 { 77 LocalTenantID: 3, 78 CheckClass: sm.CheckClass_BROWSER, 79 Duration: 45 * time.Second, 80 CostAttributionLabels: []sm.CostAttributionLabel{ 81 { 82 Name: "env", 83 Value: CalNilStringTerminator, 84 }, 85 { 86 Name: "team", 87 Value: CalNilStringTerminator, 88 }, 89 }, 90 }, 91 92 { 93 LocalTenantID: 3, 94 CheckClass: sm.CheckClass_BROWSER, 95 Duration: 15 * time.Second, 96 CostAttributionLabels: []sm.CostAttributionLabel{ 97 { 98 Name: "env", 99 Value: "prod", 100 }, 101 { 102 Name: "team", 103 Value: CalNilStringTerminator, 104 }, 105 }, 106 }, 107 { 108 LocalTenantID: 3, 109 CheckClass: sm.CheckClass_BROWSER, 110 Duration: 30 * time.Second, 111 CostAttributionLabels: []sm.CostAttributionLabel{ 112 { 113 Name: "team", 114 Value: "a", 115 }, 116 { 117 Name: "env", 118 Value: CalNilStringTerminator, 119 }, 120 }, 121 }, 122 { 123 LocalTenantID: 3, 124 CheckClass: sm.CheckClass_BROWSER, 125 Duration: 30 * time.Second, 126 CostAttributionLabels: []sm.CostAttributionLabel{ 127 { 128 Name: "team", 129 Value: "a", 130 }, 131 { 132 Name: "env", 133 Value: "b", 134 }, 135 }, 136 }, 137 }, 138 message: sm.RegionTelemetry{ 139 Instance: instance, 140 RegionId: 1, 141 Telemetry: []*sm.TenantTelemetry{ 142 { 143 TenantId: 1, 144 Telemetry: []*sm.CheckClassTelemetry{ 145 { 146 CheckClass: sm.CheckClass_PROTOCOL, 147 Executions: 2, 148 Duration: 119, 149 SampledExecutions: 2, 150 CostAttributionLabels: []sm.CostAttributionLabel{}, 151 }, 152 }, 153 }, 154 { 155 TenantId: 2, 156 Telemetry: []*sm.CheckClassTelemetry{ 157 { 158 CheckClass: sm.CheckClass_SCRIPTED, 159 Executions: 2, 160 Duration: 91, 161 SampledExecutions: 3, 162 163 CostAttributionLabels: []sm.CostAttributionLabel{}, 164 }, 165 }, 166 }, 167 { 168 TenantId: 3, 169 Telemetry: []*sm.CheckClassTelemetry{ 170 { 171 CheckClass: sm.CheckClass_BROWSER, 172 Executions: 2, 173 Duration: 91, 174 SampledExecutions: 3, 175 CostAttributionLabels: []sm.CostAttributionLabel{}, 176 }, 177 { 178 CheckClass: sm.CheckClass_BROWSER, 179 Executions: 1, 180 Duration: 45, 181 SampledExecutions: 1, 182 CostAttributionLabels: []sm.CostAttributionLabel{ 183 { 184 Name: "env", 185 Value: CalNilStringTerminator, 186 }, 187 { 188 Name: "team", 189 Value: CalNilStringTerminator, 190 }, 191 }, 192 }, 193 194 { 195 CheckClass: sm.CheckClass_BROWSER, 196 Executions: 1, 197 Duration: 15, 198 SampledExecutions: 1, 199 CostAttributionLabels: []sm.CostAttributionLabel{ 200 { 201 Name: "env", 202 Value: "prod", 203 }, 204 { 205 Name: "team", 206 Value: CalNilStringTerminator, 207 }, 208 }, 209 }, 210 { 211 CheckClass: sm.CheckClass_BROWSER, 212 Executions: 1, 213 Duration: 30, 214 SampledExecutions: 1, 215 CostAttributionLabels: []sm.CostAttributionLabel{ 216 { 217 Name: "env", 218 Value: CalNilStringTerminator, 219 }, 220 { 221 Name: "team", 222 Value: "a", 223 }, 224 }, 225 }, 226 { 227 CheckClass: sm.CheckClass_BROWSER, 228 Executions: 1, 229 Duration: 30, 230 SampledExecutions: 1, 231 CostAttributionLabels: []sm.CostAttributionLabel{ 232 { 233 Name: "env", 234 Value: "b", 235 }, 236 { 237 Name: "team", 238 Value: "a", 239 }, 240 }, 241 }, 242 }, 243 }, 244 }, 245 }, 246 }, 247 { 248 executions: []Execution{ 249 { 250 LocalTenantID: 1, 251 CheckClass: sm.CheckClass_SCRIPTED, 252 Duration: 30 * time.Second, 253 }, 254 { 255 LocalTenantID: 1, 256 CheckClass: sm.CheckClass_SCRIPTED, 257 Duration: 59 * time.Second, 258 }, 259 { 260 LocalTenantID: 1, 261 CheckClass: sm.CheckClass_PROTOCOL, 262 Duration: 130 * time.Second, 263 }, 264 { 265 LocalTenantID: 1, 266 CheckClass: sm.CheckClass_SCRIPTED, 267 Duration: 60 * time.Second, 268 }, 269 { 270 LocalTenantID: 2, 271 CheckClass: sm.CheckClass_PROTOCOL, 272 Duration: 45 * time.Second, 273 }, 274 { 275 LocalTenantID: 1, 276 CheckClass: sm.CheckClass_SCRIPTED, 277 Duration: 65 * time.Second, 278 }, 279 { 280 LocalTenantID: 3, 281 CheckClass: sm.CheckClass_BROWSER, 282 Duration: 65 * time.Second, 283 }, 284 { 285 LocalTenantID: 3, 286 CheckClass: sm.CheckClass_BROWSER, 287 Duration: 30 * time.Second, 288 CostAttributionLabels: []sm.CostAttributionLabel{ 289 { 290 Name: "env", 291 Value: CalNilStringTerminator, 292 }, 293 { 294 Name: "team", 295 Value: CalNilStringTerminator, 296 }, 297 }, 298 }, 299 300 { 301 LocalTenantID: 3, 302 CheckClass: sm.CheckClass_BROWSER, 303 Duration: 30 * time.Second, 304 CostAttributionLabels: []sm.CostAttributionLabel{ 305 { 306 Name: "env", 307 Value: "prod", 308 }, 309 { 310 Name: "team", 311 Value: CalNilStringTerminator, 312 }, 313 }, 314 }, 315 { 316 LocalTenantID: 3, 317 CheckClass: sm.CheckClass_BROWSER, 318 Duration: 30 * time.Second, 319 CostAttributionLabels: []sm.CostAttributionLabel{ 320 { 321 Name: "team", 322 Value: "a", 323 }, 324 { 325 Name: "env", 326 Value: CalNilStringTerminator, 327 }, 328 }, 329 }, 330 { 331 LocalTenantID: 3, 332 CheckClass: sm.CheckClass_BROWSER, 333 Duration: 30 * time.Second, 334 CostAttributionLabels: []sm.CostAttributionLabel{ 335 { 336 Name: "team", 337 Value: "a", 338 }, 339 { 340 Name: "env", 341 Value: "b", 342 }, 343 }, 344 }, 345 }, 346 message: sm.RegionTelemetry{ 347 Instance: instance, 348 RegionId: 1, 349 Telemetry: []*sm.TenantTelemetry{ 350 { 351 TenantId: 1, 352 Telemetry: []*sm.CheckClassTelemetry{ 353 { 354 CheckClass: sm.CheckClass_PROTOCOL, 355 Executions: 3, 356 Duration: 249, 357 SampledExecutions: 5, 358 CostAttributionLabels: []sm.CostAttributionLabel{}, 359 }, 360 { 361 CheckClass: sm.CheckClass_SCRIPTED, 362 Executions: 4, 363 Duration: 214, 364 SampledExecutions: 5, 365 CostAttributionLabels: []sm.CostAttributionLabel{}, 366 }, 367 }, 368 }, 369 { 370 TenantId: 2, 371 Telemetry: []*sm.CheckClassTelemetry{ 372 { 373 CheckClass: sm.CheckClass_PROTOCOL, 374 Executions: 1, 375 Duration: 45, 376 SampledExecutions: 1, 377 378 CostAttributionLabels: []sm.CostAttributionLabel{}, 379 }, 380 { 381 CheckClass: sm.CheckClass_SCRIPTED, 382 Executions: 2, 383 Duration: 91, 384 SampledExecutions: 3, 385 CostAttributionLabels: []sm.CostAttributionLabel{}, 386 }, 387 }, 388 }, 389 { 390 TenantId: 3, 391 Telemetry: []*sm.CheckClassTelemetry{ 392 { 393 CheckClass: sm.CheckClass_BROWSER, 394 Executions: 3, 395 Duration: 156, 396 SampledExecutions: 5, 397 CostAttributionLabels: []sm.CostAttributionLabel{}, 398 }, 399 { 400 CheckClass: sm.CheckClass_BROWSER, 401 Executions: 2, 402 Duration: 75, 403 SampledExecutions: 2, 404 CostAttributionLabels: []sm.CostAttributionLabel{ 405 { 406 Name: "env", 407 Value: CalNilStringTerminator, 408 }, 409 { 410 Name: "team", 411 Value: CalNilStringTerminator, 412 }, 413 }, 414 }, 415 416 { 417 CheckClass: sm.CheckClass_BROWSER, 418 Executions: 2, 419 Duration: 45, 420 SampledExecutions: 2, 421 CostAttributionLabels: []sm.CostAttributionLabel{ 422 { 423 Name: "env", 424 Value: "prod", 425 }, 426 { 427 Name: "team", 428 Value: CalNilStringTerminator, 429 }, 430 }, 431 }, 432 { 433 CheckClass: sm.CheckClass_BROWSER, 434 Executions: 2, 435 Duration: 60, 436 SampledExecutions: 2, 437 CostAttributionLabels: []sm.CostAttributionLabel{ 438 { 439 Name: "env", 440 Value: CalNilStringTerminator, 441 }, 442 { 443 Name: "team", 444 Value: "a", 445 }, 446 }, 447 }, 448 { 449 CheckClass: sm.CheckClass_BROWSER, 450 Executions: 2, 451 Duration: 60, 452 SampledExecutions: 2, 453 CostAttributionLabels: []sm.CostAttributionLabel{ 454 { 455 Name: "env", 456 Value: "b", 457 }, 458 { 459 Name: "team", 460 Value: "a", 461 }, 462 }, 463 }, 464 }, 465 }, 466 }, 467 }, 468 }, 469 } 470 471 // Why bother with a copy? For the same reason the above data is not a 472 // global variable: because we don't trust the caller to behave and not 473 // modify the data. 474 return testData{ 475 executions: append([]Execution{}, data[idx].executions...), 476 message: data[idx].message, 477 } 478 } 479 480 func TestTenantPusher(t *testing.T) { 481 t.Parallel() 482 483 var ( 484 testPushRespOK = testPushResp{ 485 tr: &sm.PushTelemetryResponse{ 486 Status: &sm.Status{Code: sm.StatusCode_OK}, 487 }, 488 } 489 testPushRespKO = testPushResp{ 490 tr: &sm.PushTelemetryResponse{ 491 Status: &sm.Status{Code: sm.StatusCode_INTERNAL_ERROR}, 492 }, 493 } 494 ) 495 496 addExecutions := func(p *RegionPusher, executions []Execution) { 497 for _, execution := range executions { 498 p.AddExecution(execution) 499 } 500 } 501 502 t.Run("should send telemetry data once", func(t *testing.T) { 503 t.Parallel() 504 505 synctest.Test(t, func(t *testing.T) { 506 td, tc, pusher, _ := setupTest(t) 507 508 t.Cleanup(td.shutdownAndWait) 509 510 // Add some executions. 511 addExecutions(pusher, getTestDataset(0).executions) 512 513 // Set mock response for client. 514 tc.rr = testPushRespOK 515 516 // Tick. 517 td.tickAndWait() 518 519 // Verify sent data. 520 tc.assert(t, getTestDataset(0).message) 521 }) 522 }) 523 524 t.Run("should retry sending data once", func(t *testing.T) { 525 t.Parallel() 526 527 synctest.Test(t, func(t *testing.T) { 528 td, tc, pusher, _ := setupTest(t) 529 530 t.Cleanup(td.shutdownAndWait) 531 532 // Add some executions. 533 addExecutions(pusher, getTestDataset(0).executions) 534 535 // Set mock response for client. 536 tc.rr = testPushRespKO 537 538 // Tick twice, one for initial push and one for retry. 539 td.tickAndWait() 540 td.tickAndWait() 541 542 // Verify sent data. 543 tc.assert(t, getTestDataset(0).message, getTestDataset(0).message) 544 }) 545 }) 546 547 t.Run("should retry and send more data", func(t *testing.T) { 548 t.Parallel() 549 550 synctest.Test(t, func(t *testing.T) { 551 td, tc, pusher, _ := setupTest(t) 552 553 t.Cleanup(td.shutdownAndWait) 554 555 // Add some executions. 556 addExecutions(pusher, getTestDataset(0).executions) 557 558 // Set KO mock response for client and tick once. 559 tc.rr = testPushRespKO 560 561 td.tickAndWait() 562 563 // Send more executions. 564 addExecutions(pusher, getTestDataset(1).executions) 565 566 // Set OK mock response for client and tick again. 567 tc.rr = testPushRespOK 568 569 td.tickAndWait() 570 571 // Verify sent data. 572 tc.assert(t, 573 getTestDataset(0).message, // First tick message 574 getTestDataset(1).message, // First message retry with newly accumulated data 575 ) 576 }) 577 }) 578 579 t.Run("should push on context done", func(t *testing.T) { 580 t.Parallel() 581 582 synctest.Test(t, func(t *testing.T) { 583 td, tc, pusher, _ := setupTest(t) 584 585 // Add some executions. 586 addExecutions(pusher, getTestDataset(0).executions) 587 588 // Set mock response for client. 589 tc.rr = testPushRespKO 590 591 // Tick once, which should make the push fail. 592 td.tickAndWait() 593 594 // Verify sent data. 595 tc.assert(t, getTestDataset(0).message) 596 597 // Send more executions. 598 addExecutions(pusher, getTestDataset(1).executions) 599 600 // Cancel the context, which should make the pusher send the currently accumulated data. 601 td.shutdownAndWait() 602 603 // Verify sent data on exit. 604 tc.assert(t, getTestDataset(1).message) 605 }) 606 }) 607 608 t.Run("should report push error", func(t *testing.T) { 609 t.Parallel() 610 611 synctest.Test(t, func(t *testing.T) { 612 td, tc, pusher, metrics := setupTest(t) 613 614 t.Cleanup(td.shutdownAndWait) 615 616 // Setup test client to return err on push. 617 tc.rr.err = errors.New("test error") 618 619 // Add some executions. 620 addExecutions(pusher, getTestDataset(0).executions) 621 622 // Tick once, which should make the push fail 623 td.tickAndWait() 624 625 // Verify sent data. 626 tc.assert(t, getTestDataset(0).message) 627 628 // Verify error metric. 629 // 630 // td.tickAndWait() above ensures all goroutines have completed or are durably blocked. 631 errsMetric := getMetricFromCollector(t, metrics.pushRequestsError) 632 require.Equal(t, 1, int(*errsMetric.Counter.Value)) 633 }) 634 }) 635 636 t.Run("should report push error on unexpected status", func(t *testing.T) { 637 t.Parallel() 638 639 synctest.Test(t, func(t *testing.T) { 640 td, tc, pusher, metrics := setupTest(t) 641 642 t.Cleanup(td.shutdownAndWait) 643 644 // Add some executions. 645 addExecutions(pusher, getTestDataset(0).executions) 646 647 // Set mock response for client with unexpected status code. 648 tc.rr = testPushRespKO 649 650 // Tick once, which should make the push fail. 651 td.tickAndWait() 652 653 // Verify sent data. 654 tc.assert(t, getTestDataset(0).message) 655 656 // Verify error metric. 657 // 658 // td.tickAndWait() above ensures all goroutines have completed or are durably blocked. 659 errsMetric := getMetricFromCollector(t, metrics.pushRequestsError) 660 require.Equal(t, 1, int(*errsMetric.Counter.Value)) 661 }) 662 }) 663 } 664 665 type testTicker struct { 666 c chan time.Time 667 } 668 669 func (t *testTicker) C() <-chan time.Time { 670 return t.c 671 } 672 673 func (t *testTicker) Stop() { 674 close(t.c) 675 } 676 677 type testPushResp struct { 678 tr *sm.PushTelemetryResponse 679 err error 680 } 681 682 func setupTest(t *testing.T) (*testDriver, *testTelemetryClient, *RegionPusher, RegionMetrics) { 683 // All setup happens within synctest bubble synctest will track all goroutines automatically. 684 // 685 // Do not use t.Context() here, as that context is derived from the main context, which is 686 // created outside the bubble. 687 testCtx, cancel := context.WithCancel(context.Background()) 688 t.Cleanup(cancel) 689 690 ticker := &testTicker{c: make(chan time.Time)} 691 td := testDriver{cancel: cancel, ticker: ticker} 692 tc := &testTelemetryClient{} 693 logger := testhelper.Logger(t) 694 metrics := RegionMetrics{ 695 pushRequestsActive: prom.NewGauge(prom.GaugeOpts{}), 696 pushRequestsDuration: prom.NewHistogram(prom.HistogramOpts{}), 697 pushRequestsTotal: prom.NewCounter(prom.CounterOpts{}), 698 pushRequestsError: prom.NewCounter(prom.CounterOpts{}), 699 addExecutionDuration: prom.NewHistogram(prom.HistogramOpts{}), 700 } 701 702 // Pass a WaitGroup to maintain compatibility with the production code, 703 // but synctest.Wait() will handle the synchronization. 704 wg := new(sync.WaitGroup) 705 706 pusher := NewRegionPusher( 707 testCtx, 708 1*time.Second, 709 tc, 710 logger, 711 instance, 712 regionID, 713 metrics, 714 WithTicker(ticker), 715 WithWaitGroup(wg), 716 ) 717 718 return &td, tc, pusher, metrics 719 } 720 721 type testDriver struct { 722 cancel context.CancelFunc 723 ticker *testTicker 724 } 725 726 // tickAndWait will tick the ticker once, so the push process starts, and wait for all goroutines to be durably blocked. 727 func (td *testDriver) tickAndWait() { 728 td.ticker.c <- time.Now() 729 730 synctest.Wait() // Wait for all goroutines to be durably blocked or complete. 731 } 732 733 // shutdownAndWait will cancel the context and wait for the pusher to finish. 734 func (td *testDriver) shutdownAndWait() { 735 td.cancel() 736 synctest.Wait() // Wait for pusher to finish its final push. 737 } 738 739 type testTelemetryClient struct { 740 mu sync.Mutex 741 742 rr testPushResp 743 mm []sm.RegionTelemetry 744 } 745 746 func (tc *testTelemetryClient) PushTelemetry( 747 ctx context.Context, in *sm.RegionTelemetry, opts ...grpc.CallOption, 748 ) (*sm.PushTelemetryResponse, error) { 749 tc.mu.Lock() 750 defer tc.mu.Unlock() 751 752 tc.mm = append(tc.mm, *in) 753 754 return tc.rr.tr, tc.rr.err 755 } 756 757 func (tc *testTelemetryClient) assert(t *testing.T, exp ...sm.RegionTelemetry) { 758 t.Helper() 759 760 defer func() { 761 // Copy the remaining messages in the mm buffer to the front, and clip it to that size. 762 rest := copy(tc.mm, tc.mm[len(exp):]) 763 tc.mm = tc.mm[:rest] 764 }() 765 766 for i, msg := range exp { 767 assertInfoData(t, &msg, &tc.mm[i]) 768 assertRegionTelemetryData(t, &msg, &tc.mm[i]) 769 } 770 } 771 772 func assertInfoData(t *testing.T, exp, got *sm.RegionTelemetry) { 773 t.Helper() 774 require.Equal(t, exp.Instance, got.Instance, "instances should match") 775 require.Equal(t, exp.RegionId, got.RegionId, "regions should match") 776 } 777 778 func assertRegionTelemetryData(t *testing.T, exp, got *sm.RegionTelemetry) { 779 t.Helper() 780 require.Equal(t, len(exp.Telemetry), len(got.Telemetry), "region telemetry length should match") 781 782 // Because the message is built in the pusher by iterating a map, the 783 // order is not deterministic, therefore we have to find each element 784 LOOP: 785 for _, expTenantTele := range exp.Telemetry { 786 for j, gotTenantTele := range got.Telemetry { 787 if expTenantTele.TenantId == gotTenantTele.TenantId { 788 assertTenantTelemetryData(t, expTenantTele, gotTenantTele) 789 790 got.Telemetry = append(got.Telemetry[:j], got.Telemetry[j+1:]...) 791 792 continue LOOP 793 } 794 } 795 796 t.Fatalf("region telemetry not found: %v", expTenantTele) 797 } 798 } 799 800 func assertTenantTelemetryData(t *testing.T, exp, got *sm.TenantTelemetry) { 801 t.Helper() 802 require.Equal(t, len(exp.Telemetry), len(got.Telemetry), "tenant telemetry length should match") 803 804 LOOP: 805 for _, expTele := range exp.Telemetry { 806 for j, gotTele := range got.Telemetry { 807 if reflect.DeepEqual(expTele, gotTele) { 808 got.Telemetry = append(got.Telemetry[:j], got.Telemetry[j+1:]...) 809 continue LOOP 810 } 811 } 812 813 t.Fatalf("tenant telemetry not found: %v", expTele) 814 } 815 } 816 817 func getMetricFromCollector(t *testing.T, c prom.Collector) *prommodel.Metric { 818 t.Helper() 819 820 metricCh := make(chan prom.Metric) 821 defer close(metricCh) 822 823 go c.Collect(metricCh) 824 825 metric := <-metricCh 826 827 m := &prommodel.Metric{} 828 require.NoError(t, metric.Write(m)) 829 830 return m 831 }