github.com/grafana/synthetic-monitoring-agent@v0.64.2/internal/adhoc/adhoc.go (about) 1 package adhoc 2 3 import ( 4 "bytes" 5 "context" 6 "errors" 7 "fmt" 8 "io" 9 "time" 10 11 "github.com/google/uuid" 12 "github.com/prometheus/client_golang/prometheus" 13 prompb "github.com/prometheus/prometheus/prompb" 14 "github.com/rs/zerolog" 15 "google.golang.org/grpc" 16 "google.golang.org/grpc/codes" 17 "google.golang.org/grpc/connectivity" 18 "google.golang.org/grpc/status" 19 20 logproto "github.com/grafana/loki/pkg/push" 21 "github.com/grafana/synthetic-monitoring-agent/internal/feature" 22 "github.com/grafana/synthetic-monitoring-agent/internal/k6runner" 23 "github.com/grafana/synthetic-monitoring-agent/internal/model" 24 "github.com/grafana/synthetic-monitoring-agent/internal/prober" 25 "github.com/grafana/synthetic-monitoring-agent/internal/pusher" 26 "github.com/grafana/synthetic-monitoring-agent/internal/secrets" 27 "github.com/grafana/synthetic-monitoring-agent/internal/version" 28 sm "github.com/grafana/synthetic-monitoring-agent/pkg/pb/synthetic_monitoring" 29 ) 30 31 // Handler is in charge of retrieving ad-hoc checks from the 32 // synthetic-monitoring-api, running them and forwarding the results to 33 // the publisher. 34 type Handler struct { 35 api apiInfo 36 logger zerolog.Logger 37 features feature.Collection 38 backoff Backoffer 39 probe *sm.Probe 40 metrics metrics 41 publisher pusher.Publisher 42 tenantCh chan<- sm.Tenant 43 runnerFactory func(context.Context, *sm.AdHocRequest) (*runner, error) 44 grpcAdhocChecksClientFactory func(conn ClientConn) (sm.AdHocChecksClient, error) 45 proberFactory prober.ProberFactory 46 supportsProtocolSecrets bool 47 } 48 49 // Error represents errors returned from this package. 50 type Error string 51 52 func (e Error) Error() string { return string(e) } 53 54 const ( 55 errNotAuthorized = Error("probe not authorized") 56 errTransportClosing = Error("transport closing") 57 errProbeUnregistered = Error("probe no longer registered") 58 errIncompatibleApi = Error("API does not support required features") 59 errInvalidAdHocRequest = Error("invalid ad-hoc request") 60 errIdleTimeout = Error("connection idle timeout") 61 62 k6AdhocGraceTime = 20 * time.Second 63 ) 64 65 // Removed HTTP status code parsing; we treat all codes.Unavailable as idle timeout. 66 67 type runner struct { 68 logger zerolog.Logger 69 prober prober.Prober 70 id string 71 target string 72 probe string 73 timeout time.Duration 74 } 75 76 // ClientConn represents the GRPC client connection that can be used to 77 // make RPC calls to the Synthetic Monitoring API. 78 type ClientConn interface { 79 grpc.ClientConnInterface 80 GetState() connectivity.State 81 } 82 83 type apiInfo struct { 84 conn ClientConn 85 } 86 87 type metrics struct { 88 opsCounter *prometheus.CounterVec 89 } 90 91 // Backoffer defines an interface to provide backoff durations. 92 // 93 // The implementation of this interface SHOULD NOT perform the actual 94 // sleep, but rather return the duration to sleep. 95 type Backoffer interface { 96 // Reset causes the backoff provider to go back its initial 97 // state, before any calls to Duration() were made. 98 Reset() 99 // Duration returns the duration to sleep. 100 Duration() time.Duration 101 } 102 103 type constantBackoff time.Duration 104 105 func (constantBackoff) Reset() {} 106 107 func (b constantBackoff) Duration() time.Duration { return time.Duration(b) } 108 109 // HandlerOpts is used to pass configuration options to the Handler. 110 type HandlerOpts struct { 111 Conn ClientConn 112 Logger zerolog.Logger 113 Backoff Backoffer 114 Publisher pusher.Publisher 115 TenantCh chan<- sm.Tenant 116 PromRegisterer prometheus.Registerer 117 Features feature.Collection 118 K6Runner k6runner.Runner 119 SecretProvider secrets.SecretProvider 120 SupportsProtocolSecrets bool 121 122 // these two fields exists so that tests can pass alternate 123 // implementations, they are unexported so that clients of this 124 // package always use the default ones. 125 runnerFactory func(context.Context, *sm.AdHocRequest) (*runner, error) 126 grpcAdhocChecksClientFactory func(conn ClientConn) (sm.AdHocChecksClient, error) 127 } 128 129 // NewHandler creates a new Handler using the specified options. 130 func NewHandler(opts HandlerOpts) (*Handler, error) { 131 opsCounter := prometheus.NewCounterVec( 132 prometheus.CounterOpts{ 133 Namespace: "sm", 134 Subsystem: "adhoc", 135 Name: "ops_total", 136 Help: "Total number of adhoc operations", 137 }, 138 []string{"type"}, 139 ) 140 141 if err := opts.PromRegisterer.Register(opsCounter); err != nil { 142 return nil, err 143 } 144 145 h := &Handler{ 146 logger: opts.Logger, 147 features: opts.Features, 148 backoff: opts.Backoff, 149 publisher: opts.Publisher, 150 tenantCh: opts.TenantCh, 151 runnerFactory: opts.runnerFactory, 152 grpcAdhocChecksClientFactory: opts.grpcAdhocChecksClientFactory, 153 proberFactory: prober.NewProberFactory(opts.K6Runner, 0, opts.Features, opts.SecretProvider), 154 supportsProtocolSecrets: opts.SupportsProtocolSecrets, 155 api: apiInfo{ 156 conn: opts.Conn, 157 }, 158 metrics: metrics{ 159 opsCounter: opsCounter, 160 }, 161 } 162 163 if opts.runnerFactory == nil { 164 h.runnerFactory = h.defaultRunnerFactory 165 } 166 167 if opts.grpcAdhocChecksClientFactory == nil { 168 h.grpcAdhocChecksClientFactory = defaultGrpcAdhocChecksClientFactory 169 } 170 171 if opts.Backoff == nil { 172 h.backoff = constantBackoff(60 * time.Second) 173 } 174 175 return h, nil 176 } 177 178 // Run starts the handler. 179 func (h *Handler) Run(ctx context.Context) error { 180 for { 181 err := h.loop(ctx) 182 switch { 183 case err == nil: 184 return nil 185 186 case errors.Is(err, errNotAuthorized): 187 // our token is invalid, bail out? 188 h.logger.Error(). 189 Err(err). 190 Str("connection_state", h.api.conn.GetState().String()). 191 Msg("cannot connect, bailing out") 192 193 return err 194 195 case errors.Is(err, errIncompatibleApi): 196 // API server doesn't support required features. 197 h.logger.Error(). 198 Err(err). 199 Str("connection_state", h.api.conn.GetState().String()). 200 Msg("cannot connect, bailing out") 201 202 return err 203 204 case errors.Is(err, errTransportClosing): 205 // the other end went away? Allow GRPC to reconnect. 206 h.logger.Warn(). 207 Err(err). 208 Str("connection_state", h.api.conn.GetState().String()). 209 Msg("the other end closed the connection, trying to reconnect") 210 211 // After disconnecting, reset the backoff 212 // counter to start afresh. 213 h.backoff.Reset() 214 215 continue 216 217 case errors.Is(err, errIdleTimeout): 218 // connection idle timeout - this is normal behavior when adhoc checks aren't used frequently 219 h.logger.Info(). 220 Str("connection_state", h.api.conn.GetState().String()). 221 Msg("connection idle timeout, reconnecting") 222 223 // After idle timeout, reset the backoff counter to start afresh. 224 h.backoff.Reset() 225 226 // Sleep to allow context cancellation to be checked 227 if err := sleepCtx(ctx, h.backoff.Duration()); err != nil { 228 return err 229 } 230 231 continue 232 233 case errors.Is(err, context.Canceled): 234 // context was cancelled, clean up 235 h.logger.Error(). 236 Err(err). 237 Str("connection_state", h.api.conn.GetState().String()). 238 Msg("context cancelled, closing updater") 239 240 return nil 241 242 case errors.Is(err, errProbeUnregistered): 243 // Probe unregistered itself from the API, wait 244 // until attempting to reconnect again. 245 h.logger.Warn(). 246 Str("connection_state", h.api.conn.GetState().String()). 247 Msg("unregistered probe in API, sleeping for 1 minute...") 248 249 if err := sleepCtx(ctx, 1*time.Minute); err != nil { 250 return err 251 } 252 253 // The probe is going to reconnect, reset the 254 // backoff counter to start afresh. 255 h.backoff.Reset() 256 257 default: 258 h.logger.Warn(). 259 Err(err). 260 Str("connection_state", h.api.conn.GetState().String()). 261 Msg("handling check changes") 262 263 // TODO(mem): this might be a transient error (e.g. bad connection). We probably need to 264 // fine-tune GRPPC's backoff parameters. We might also need to keep count of the reconnects, and 265 // give up if they hit some threshold? 266 if err := sleepCtx(ctx, h.backoff.Duration()); err != nil { 267 return err 268 } 269 } 270 } 271 } 272 273 func (h *Handler) loop(ctx context.Context) error { 274 h.logger.Info().Msg("fetching ad-hoc checks from synthetic-monitoring-api") 275 276 client, err := h.grpcAdhocChecksClientFactory(h.api.conn) 277 if err != nil { 278 return fmt.Errorf("cannot create adhoc checks client: %w", err) 279 } 280 281 grpcErrorHandler := func(action string, err error) error { 282 status, ok := status.FromError(err) 283 284 switch { 285 case !ok: 286 h.logger.Error().Err(err).Bool("ok", ok).Str("action", action).Msg("unknown error") 287 return fmt.Errorf("%s: %w", action, err) 288 289 case status.Code() == codes.Canceled: 290 // either we were told to shut down 291 return context.Canceled 292 293 case status.Message() == "transport is closing": 294 // the other end is shutting down 295 return errTransportClosing 296 297 case status.Code() == codes.Unavailable: 298 // Treat all Unavailable as idle timeout; allow reconnect loop. 299 return errIdleTimeout 300 301 case status.Code() == codes.PermissionDenied: 302 h.logger.Error().Err(err).Bool("ok", ok).Str("action", action).Uint32("code", uint32(status.Code())).Msg(status.Message()) 303 return errNotAuthorized 304 305 case status.Code() == codes.Unimplemented: 306 h.logger.Error().Err(err).Bool("ok", ok).Str("action", action).Uint32("code", uint32(status.Code())).Msg(status.Message()) 307 return errIncompatibleApi 308 309 default: 310 h.logger.Error().Err(err).Bool("ok", ok).Str("action", action).Uint32("code", uint32(status.Code())).Msg(status.Message()) 311 return status.Err() 312 } 313 } 314 315 result, err := client.RegisterProbe( 316 ctx, 317 &sm.ProbeInfo{ 318 Version: version.Short(), 319 Commit: version.Commit(), 320 Buildstamp: version.Buildstamp(), 321 SupportsProtocolSecrets: h.supportsProtocolSecrets, 322 }, 323 ) 324 if err != nil { 325 return grpcErrorHandler( 326 "registering ad-hoc probe with synthetic-monitoring-api", 327 err) 328 } 329 330 switch result.Status.Code { 331 case sm.StatusCode_OK: 332 // continue 333 334 case sm.StatusCode_NOT_AUTHORIZED: 335 return errNotAuthorized 336 337 default: 338 return fmt.Errorf( 339 "registering ad-hoc probe with synthetic-monitoring-api, response: %s", 340 result.Status.Message) 341 } 342 343 h.probe = &result.Probe 344 345 h.logger.Info(). 346 Int64("probe id", h.probe.Id). 347 Str("probe name", h.probe.Name). 348 Msg("registered ad-hoc probe with synthetic-monitoring-api") 349 350 requests, err := client.GetAdHocChecks(ctx, &sm.Void{}) 351 if err != nil { 352 return grpcErrorHandler("requesting ad-hoc checks from synthetic-monitoring-api", err) 353 } 354 355 return h.processAdHocChecks(ctx, requests) 356 } 357 358 func (h *Handler) processAdHocChecks(ctx context.Context, client sm.AdHocChecks_GetAdHocChecksClient) error { 359 for { 360 select { 361 case <-client.Context().Done(): 362 return nil 363 364 case <-ctx.Done(): 365 return ctx.Err() 366 367 default: 368 switch msg, err := client.Recv(); err { 369 case nil: 370 if err := h.handleAdHocCheck(ctx, msg); err != nil { 371 h.logger.Error().Err(err).Interface("request", msg).Msg("handling ad-hoc check") 372 continue 373 } 374 375 case io.EOF: 376 h.logger.Warn().Err(err).Msg("no more messages?") 377 // XXX(mem): what happened here? The 378 // other end told us there are no more 379 // changes. Stop? Is it restarting? 380 return nil 381 382 default: 383 h.logger.Error().Err(err).Msg("receiving ad-hoc check") 384 return err 385 } 386 } 387 } 388 } 389 390 func (h *Handler) handleAdHocCheck(ctx context.Context, ahReq *sm.AdHocRequest) error { 391 h.logger.Debug().Interface("request", ahReq).Msg("got ad-hoc check request") 392 393 h.metrics.opsCounter.WithLabelValues(ahReq.AdHocCheck.Type().String()).Inc() 394 395 runner, err := h.runnerFactory(ctx, ahReq) 396 if err != nil { 397 // TODO(mem): if the runner factory returns an error, we should 398 // create a result that reflects that and publish it, so that 399 // the frontend is able to communicate to the user that 400 // something went wrong. 401 return err 402 } 403 404 go runner.Run(ctx, model.GlobalID(ahReq.AdHocCheck.TenantId), h.publisher) 405 406 // If there's a tenant in the request, this should be forwarded 407 // to the changes handler. 408 if ahReq.Tenant != nil { 409 h.tenantCh <- *ahReq.Tenant 410 } 411 412 return nil 413 } 414 415 func defaultGrpcAdhocChecksClientFactory(conn ClientConn) (sm.AdHocChecksClient, error) { 416 cc, ok := conn.(*grpc.ClientConn) 417 if !ok { 418 return nil, fmt.Errorf("unexpected type of connection: %T", conn) 419 } 420 421 return sm.NewAdHocChecksClient(cc), nil 422 } 423 424 func (h *Handler) defaultRunnerFactory(ctx context.Context, req *sm.AdHocRequest) (*runner, error) { 425 // This should never happen. If we hit this, it's a bug in the code 426 // that is handling the request, or the API sent us an invalid request. 427 if req == nil || req.AdHocCheck.TenantId == 0 { 428 return nil, errInvalidAdHocRequest 429 } 430 431 check := model.Check{ 432 Check: sm.Check{ 433 TenantId: req.AdHocCheck.TenantId, 434 Target: req.AdHocCheck.Target, 435 Timeout: req.AdHocCheck.Timeout, 436 Settings: req.AdHocCheck.Settings, 437 Channels: req.AdHocCheck.Channels, 438 439 // All the following fields are not used for ad-hoc checks. 440 Id: 0, // ad-hoc checks don't have an ID in this sense. 441 Job: "", 442 Frequency: 0, 443 Offset: 0, 444 Enabled: true, 445 Labels: nil, 446 Probes: nil, 447 BasicMetricsOnly: false, 448 AlertSensitivity: "", 449 Created: 0, 450 Modified: 0, 451 }, 452 } 453 454 p, target, err := h.proberFactory.New(ctx, h.logger, check) 455 if err != nil { 456 return nil, err 457 } 458 459 // Ad-hoc checks that rely on k6 runners need some grace time to give the runner time to communicate a timeout back 460 // to us, instead of both us and the runner timing out at the same time and missing the runner's response. We define 461 // that grace time to be 20 seconds, to be consistent with the grace time used for k6, non ad-hoc checks. 462 timeout := time.Duration(check.Timeout) * time.Millisecond 463 switch check.Type() { 464 case sm.CheckTypeMultiHttp, sm.CheckTypeScripted, sm.CheckTypeBrowser: 465 timeout += k6AdhocGraceTime 466 } 467 468 return &runner{ 469 logger: h.logger, 470 prober: p, 471 id: req.AdHocCheck.Id, 472 target: target, 473 probe: h.probe.Name, 474 timeout: timeout, 475 }, nil 476 } 477 478 // sleepCtx is like time.Sleep, but it pays attention to the 479 // cancellation of the provided context. 480 func sleepCtx(ctx context.Context, d time.Duration) error { 481 var err error 482 483 timer := time.NewTimer(d) 484 485 select { 486 case <-ctx.Done(): 487 err = ctx.Err() 488 489 if !timer.Stop() { 490 <-timer.C 491 } 492 493 case <-timer.C: 494 } 495 496 return err 497 } 498 499 // jsonLogger implements the log.Logger interface. 500 type jsonLogger struct { 501 entries []map[string]string 502 } 503 504 // Log takes key-value pairs and logs them. 505 func (l *jsonLogger) Log(keyvals ...any) error { 506 m := make(map[string]string) 507 508 if len(keyvals)%2 != 0 { 509 return fmt.Errorf("expected even number of keyvals, got %d", len(keyvals)) 510 } 511 512 for i := 0; i < len(keyvals); i += 2 { 513 k := fmt.Sprintf("%v", keyvals[i]) 514 v := fmt.Sprintf("%v", keyvals[i+1]) 515 m[k] = v 516 } 517 518 l.entries = append(l.entries, m) 519 520 return nil 521 } 522 523 // Run runs the specified prober once and captures the results using 524 // jsonLogger. 525 func (r *runner) Run(ctx context.Context, tenantId model.GlobalID, publisher pusher.Publisher) { 526 r.logger.Info().Msg("running ad-hoc check") 527 528 var ( 529 logger = &jsonLogger{} 530 registry = prometheus.NewRegistry() 531 // This is the execution ID for the check run. 532 executionID = uuid.New().String() 533 ) 534 535 // TODO(mem): decide what to do with these metrics. 536 successGauge := prometheus.NewGauge(prometheus.GaugeOpts{ 537 Name: "probe_success", 538 Help: "whether the check was successful", 539 }) 540 541 registry.MustRegister(successGauge) 542 543 durationGauge := prometheus.NewGauge(prometheus.GaugeOpts{ 544 Name: "probe_duration_seconds", 545 Help: "duration of the check in seconds", 546 }) 547 548 registry.MustRegister(durationGauge) 549 550 start := time.Now() 551 552 rCtx, cancel := context.WithTimeout(ctx, r.timeout) 553 defer cancel() 554 555 success, duration := r.prober.Probe(rCtx, r.target, registry, logger, executionID) 556 557 if success { 558 successGauge.Set(1) 559 } else { 560 successGauge.Set(0) 561 } 562 563 if duration != 0 { 564 durationGauge.Set(duration) 565 } else { 566 durationGauge.Set(float64(time.Since(start).Microseconds()) / 1e6) 567 } 568 569 mfs, err := registry.Gather() 570 571 buf := &bytes.Buffer{} 572 targetLogger := zerolog.New(buf) 573 574 targetLogger.Warn(). 575 AnErr("error", err). 576 Str("id", r.id). 577 Str("target", r.target). 578 Str("probe", r.probe). 579 Str("check_name", r.prober.Name()). 580 Interface("logs", logger.entries). 581 Interface("timeseries", mfs). 582 Msg("ad-hoc check done") 583 584 r.logger.Debug(). 585 Str("id", r.id). 586 Str("target", r.target). 587 Str("probe", r.probe). 588 Str("check_name", r.prober.Name()). 589 Msg("ad-hoc check done") 590 591 publisher.Publish(adhocData{ 592 tenantId: tenantId, 593 streams: Streams{ 594 { 595 Labels: fmt.Sprintf(`{probe=%q,source="synthetic-monitoring",type="adhoc"}`, r.probe), 596 Entries: []logproto.Entry{ 597 { 598 Timestamp: start, 599 Line: buf.String(), 600 StructuredMetadata: logproto.LabelsAdapter{ 601 {Name: "execution_id", Value: executionID}, 602 }, 603 }, 604 }, 605 }, 606 }, 607 }) 608 609 r.logger.Debug(). 610 Str("id", r.id). 611 Str("target", r.target). 612 Str("probe", r.probe). 613 Str("check_name", r.prober.Name()). 614 Msg("ad-hoc result sent to publisher") 615 } 616 617 type ( 618 TimeSeries = []prompb.TimeSeries 619 Streams = []logproto.Stream 620 ) 621 622 type adhocData struct { 623 tenantId model.GlobalID 624 streams Streams 625 } 626 627 func (d adhocData) Metrics() TimeSeries { 628 return nil 629 } 630 631 func (d adhocData) Streams() Streams { 632 return d.streams 633 } 634 635 func (d adhocData) Tenant() model.GlobalID { 636 return d.tenantId 637 }