go.undefinedlabs.com/scopeagent@v0.4.2/agent/agent.go (about) 1 package agent 2 3 import ( 4 "errors" 5 "fmt" 6 "io/ioutil" 7 "log" 8 "net/url" 9 "os" 10 "path" 11 "path/filepath" 12 "runtime" 13 "strings" 14 "sync" 15 "testing" 16 "time" 17 18 "github.com/google/uuid" 19 "github.com/mitchellh/go-homedir" 20 "github.com/opentracing/opentracing-go" 21 22 "go.undefinedlabs.com/scopeagent/env" 23 scopeError "go.undefinedlabs.com/scopeagent/errors" 24 "go.undefinedlabs.com/scopeagent/instrumentation" 25 scopetesting "go.undefinedlabs.com/scopeagent/instrumentation/testing" 26 "go.undefinedlabs.com/scopeagent/reflection" 27 "go.undefinedlabs.com/scopeagent/runner" 28 "go.undefinedlabs.com/scopeagent/tags" 29 "go.undefinedlabs.com/scopeagent/tracer" 30 ) 31 32 type ( 33 Agent struct { 34 tracer opentracing.Tracer 35 36 apiEndpoint string 37 apiKey string 38 39 agentId string 40 version string 41 metadata map[string]interface{} 42 debugMode bool 43 testingMode bool 44 setGlobalTracer bool 45 panicAsFail bool 46 failRetriesCount int 47 48 recorder *SpanRecorder 49 recorderFilename string 50 flushFrequency time.Duration 51 52 optionalRecorders []tracer.SpanRecorder 53 54 userAgent string 55 agentType string 56 57 logger *log.Logger 58 printReportOnce sync.Once 59 60 cache *localCache 61 } 62 63 Option func(*Agent) 64 ) 65 66 var ( 67 version = "0.4.2" 68 69 testingModeFrequency = time.Second 70 nonTestingModeFrequency = time.Minute 71 ) 72 73 func WithApiKey(apiKey string) Option { 74 return func(agent *Agent) { 75 agent.apiKey = apiKey 76 } 77 } 78 79 func WithApiEndpoint(apiEndpoint string) Option { 80 return func(agent *Agent) { 81 agent.apiEndpoint = apiEndpoint 82 } 83 } 84 85 func WithServiceName(service string) Option { 86 return func(agent *Agent) { 87 agent.metadata[tags.Service] = service 88 } 89 } 90 91 func WithDebugEnabled() Option { 92 return func(agent *Agent) { 93 agent.debugMode = true 94 } 95 } 96 97 func WithTestingModeEnabled() Option { 98 return func(agent *Agent) { 99 agent.testingMode = true 100 } 101 } 102 103 func WithSetGlobalTracer() Option { 104 return func(agent *Agent) { 105 agent.setGlobalTracer = true 106 } 107 } 108 109 func WithMetadata(values map[string]interface{}) Option { 110 return func(agent *Agent) { 111 for k, v := range values { 112 agent.metadata[k] = v 113 } 114 } 115 } 116 117 func WithGitInfo(repository string, commitSha string, sourceRoot string) Option { 118 return func(agent *Agent) { 119 agent.metadata[tags.Repository] = repository 120 agent.metadata[tags.Commit] = commitSha 121 agent.metadata[tags.SourceRoot] = sourceRoot 122 } 123 } 124 125 func WithUserAgent(userAgent string) Option { 126 return func(agent *Agent) { 127 userAgent = strings.TrimSpace(userAgent) 128 if userAgent != "" { 129 agent.userAgent = userAgent 130 } 131 } 132 } 133 134 func WithAgentType(agentType string) Option { 135 return func(agent *Agent) { 136 agentType = strings.TrimSpace(agentType) 137 if agentType != "" { 138 agent.agentType = agentType 139 } 140 } 141 } 142 143 func WithConfigurationKeys(keys []string) Option { 144 return func(agent *Agent) { 145 if keys != nil && len(keys) > 0 { 146 agent.metadata[tags.ConfigurationKeys] = keys 147 } 148 } 149 } 150 151 func WithConfiguration(values map[string]interface{}) Option { 152 return func(agent *Agent) { 153 if values == nil { 154 return 155 } 156 var keys []string 157 for k, v := range values { 158 agent.metadata[k] = v 159 keys = append(keys, k) 160 } 161 agent.metadata[tags.ConfigurationKeys] = keys 162 } 163 } 164 165 func WithRetriesOnFail(retriesCount int) Option { 166 return func(agent *Agent) { 167 agent.failRetriesCount = retriesCount 168 } 169 } 170 171 func WithHandlePanicAsFail() Option { 172 return func(agent *Agent) { 173 agent.panicAsFail = true 174 } 175 } 176 177 func WithRecorders(recorders ...tracer.SpanRecorder) Option { 178 return func(agent *Agent) { 179 agent.optionalRecorders = recorders 180 } 181 } 182 183 func WithGlobalPanicHandler() Option { 184 return func(agent *Agent) { 185 reflection.AddPanicHandler(func(e interface{}) { 186 instrumentation.Logger().Printf("Panic handler triggered by: %v.\nFlushing agent, sending partial results...", scopeError.GetCurrentError(e).ErrorStack()) 187 agent.Flush() 188 }) 189 reflection.AddOnPanicExitHandler(func(e interface{}) { 190 instrumentation.Logger().Printf("Process is going to end by: %v,\nStopping agent...", scopeError.GetCurrentError(e).ErrorStack()) 191 scopetesting.PanicAllRunningTests(e, 3) 192 agent.Stop() 193 }) 194 } 195 } 196 197 // Creates a new Scope Agent instance 198 func NewAgent(options ...Option) (*Agent, error) { 199 agent := new(Agent) 200 agent.metadata = make(map[string]interface{}) 201 agent.version = version 202 agent.agentId = generateAgentID() 203 agent.userAgent = fmt.Sprintf("scope-agent-go/%s", agent.version) 204 agent.panicAsFail = false 205 agent.failRetriesCount = 0 206 207 for _, opt := range options { 208 opt(agent) 209 } 210 211 if err := agent.setupLogging(); err != nil { 212 agent.logger = log.New(ioutil.Discard, "", 0) 213 } 214 215 agent.debugMode = agent.debugMode || env.ScopeDebug.Value 216 217 configProfile := GetConfigCurrentProfile() 218 219 if agent.apiKey == "" || agent.apiEndpoint == "" { 220 if dsn, set := env.ScopeDsn.Tuple(); set && dsn != "" { 221 dsnApiKey, dsnApiEndpoint, dsnErr := parseDSN(dsn) 222 if dsnErr != nil { 223 agent.logger.Printf("Error parsing dsn value: %v\n", dsnErr) 224 } else { 225 agent.apiKey = dsnApiKey 226 agent.apiEndpoint = dsnApiEndpoint 227 } 228 } else { 229 agent.logger.Println("environment variable $SCOPE_DSN not found") 230 } 231 } 232 233 if agent.apiKey == "" { 234 if apiKey, set := env.ScopeApiKey.Tuple(); set && apiKey != "" { 235 agent.apiKey = apiKey 236 } else if configProfile != nil { 237 agent.logger.Println("API key found in the native app configuration") 238 agent.apiKey = configProfile.ApiKey 239 } else { 240 agent.logger.Println("API key not found, agent can't be started") 241 return nil, errors.New("Scope DSN not found. Tests will run but no results will be reported to Scope. More info at https://docs.scope.dev/") 242 } 243 } 244 245 if agent.apiEndpoint == "" { 246 if endpoint, set := env.ScopeApiEndpoint.Tuple(); set && endpoint != "" { 247 agent.apiEndpoint = endpoint 248 } else if configProfile != nil { 249 agent.logger.Println("API endpoint found in the native app configuration") 250 agent.apiEndpoint = configProfile.ApiEndpoint 251 } else { 252 agent.logger.Printf("using default endpoint: %v\n", endpoint) 253 agent.apiEndpoint = endpoint 254 } 255 } 256 257 // Agent data 258 if agent.agentType == "" { 259 agent.agentType = "go" 260 } 261 agent.metadata[tags.AgentID] = agent.agentId 262 agent.metadata[tags.AgentVersion] = version 263 agent.metadata[tags.AgentType] = agent.agentType 264 agent.metadata[tags.TestingMode] = agent.testingMode 265 266 // Platform data 267 agent.metadata[tags.PlatformName] = runtime.GOOS 268 agent.metadata[tags.PlatformArchitecture] = runtime.GOARCH 269 if runtime.GOARCH == "amd64" { 270 agent.metadata[tags.ProcessArchitecture] = "X64" 271 } else if runtime.GOARCH == "386" { 272 agent.metadata[tags.ProcessArchitecture] = "X86" 273 } else if runtime.GOARCH == "arm" { 274 agent.metadata[tags.ProcessArchitecture] = "Arm" 275 } else if runtime.GOARCH == "arm64" { 276 agent.metadata[tags.ProcessArchitecture] = "Arm64" 277 } 278 279 // Current folder 280 wd, _ := os.Getwd() 281 agent.metadata[tags.CurrentFolder] = filepath.Clean(wd) 282 283 // Hostname 284 hostname, _ := os.Hostname() 285 agent.metadata[tags.Hostname] = hostname 286 287 // Go version 288 agent.metadata[tags.GoVersion] = runtime.Version() 289 290 // Service name 291 addElementToMapIfEmpty(agent.metadata, tags.Service, env.ScopeService.Value) 292 293 // Configurations 294 addElementToMapIfEmpty(agent.metadata, tags.ConfigurationKeys, env.ScopeConfiguration.Value) 295 296 // Metadata 297 addToMapIfEmpty(agent.metadata, env.ScopeMetadata.Value) 298 299 // Git data 300 addToMapIfEmpty(agent.metadata, getGitInfoFromEnv()) 301 addToMapIfEmpty(agent.metadata, getCIMetadata()) 302 addToMapIfEmpty(agent.metadata, getGitInfoFromGitFolder()) 303 304 agent.metadata[tags.Diff] = getGitDiff() 305 306 agent.metadata[tags.InContainer] = isRunningInContainer() 307 308 // Dependencies 309 agent.metadata[tags.Dependencies] = getDependencyMap() 310 311 // Expand '~' in source root 312 var sourceRoot string 313 if sRoot, ok := agent.metadata[tags.SourceRoot]; ok { 314 cSRoot := sRoot.(string) 315 cSRoot = filepath.Clean(cSRoot) 316 if sRootEx, err := homedir.Expand(cSRoot); err == nil { 317 cSRoot = sRootEx 318 } 319 sourceRoot = cSRoot 320 } 321 if sourceRoot == "" { 322 sourceRoot = getGoModDir() 323 } 324 agent.metadata[tags.SourceRoot] = sourceRoot 325 326 // Capabilities 327 capabilities := map[string]interface{}{ 328 tags.Capabilities_CodePath: testing.CoverMode() != "", 329 tags.Capabilities_RunnerCache: false, 330 tags.Capabilities_RunnerRetries: agent.failRetriesCount > 0, 331 } 332 agent.metadata[tags.Capabilities] = capabilities 333 334 enableRemoteConfig := false 335 if env.ScopeRunnerEnabled.Value { 336 // runner is enabled 337 capabilities[tags.Capabilities_RunnerCache] = true 338 if env.ScopeRunnerIncludeBranches.Value == nil && env.ScopeRunnerExcludeBranches.Value == nil { 339 // both include and exclude branches are not defined 340 enableRemoteConfig = true 341 } else if iBranch, ok := agent.metadata[tags.Branch]; ok { 342 branch := iBranch.(string) 343 included := sliceContains(env.ScopeRunnerIncludeBranches.Value, branch) 344 excluded := sliceContains(env.ScopeRunnerExcludeBranches.Value, branch) 345 enableRemoteConfig = included // By default we use the value inside the include slice 346 if env.ScopeRunnerExcludeBranches.Value != nil { 347 if included && excluded { 348 // If appears in both slices, write in the logger and disable the runner configuration 349 agent.logger.Printf("The branch '%v' appears in both included and excluded branches. The branch will be excluded.", branch) 350 enableRemoteConfig = false 351 } else { 352 // We enable the remote config if is include or not excluded 353 enableRemoteConfig = included || !excluded 354 } 355 } 356 } 357 } 358 359 if !agent.testingMode { 360 if env.ScopeTestingMode.IsSet { 361 agent.testingMode = env.ScopeTestingMode.Value 362 } else { 363 agent.testingMode = agent.metadata[tags.CI].(bool) 364 } 365 } 366 367 if agent.failRetriesCount == 0 { 368 agent.failRetriesCount = env.ScopeTestingFailRetries.Value 369 } 370 agent.panicAsFail = agent.panicAsFail || env.ScopeTestingPanicAsFail.Value 371 372 agent.flushFrequency = nonTestingModeFrequency 373 if agent.testingMode { 374 agent.flushFrequency = testingModeFrequency 375 } 376 377 if agent.debugMode { 378 agent.logMetadata() 379 } 380 381 // 382 agent.cache = newLocalCache(agent.getRemoteConfigRequest(), cacheTimeout, agent.debugMode, agent.logger) 383 384 agent.recorder = NewSpanRecorder(agent) 385 var recorder tracer.SpanRecorder = agent.recorder 386 if agent.optionalRecorders != nil { 387 recorders := append(agent.optionalRecorders, agent.recorder) 388 recorder = tracer.NewMultiRecorder(recorders...) 389 } 390 391 agent.tracer = tracer.NewWithOptions(tracer.Options{ 392 Recorder: recorder, 393 ShouldSample: func(traceID uuid.UUID) bool { 394 return true 395 }, 396 MaxLogsPerSpan: 10000, 397 // Log the error in the current span 398 OnSpanFinishPanic: scopeError.WriteExceptionEventInRawSpan, 399 }) 400 instrumentation.SetTracer(agent.tracer) 401 instrumentation.SetLogger(agent.logger) 402 instrumentation.SetSourceRoot(sourceRoot) 403 if enableRemoteConfig { 404 instrumentation.SetRemoteConfiguration(agent.loadRemoteConfiguration()) 405 } 406 if agent.setGlobalTracer || env.ScopeTracerGlobal.Value { 407 opentracing.SetGlobalTracer(agent.Tracer()) 408 } 409 410 return agent, nil 411 } 412 413 func getGoModDir() string { 414 dir, err := os.Getwd() 415 if err != nil { 416 return filepath.Dir("/") 417 } 418 for { 419 rel, _ := filepath.Rel("/", dir) 420 // Exit the loop once we reach the basePath. 421 if rel == "." { 422 return filepath.Dir("/") 423 } 424 modPath := fmt.Sprintf("%v/go.mod", dir) 425 if _, err := os.Stat(modPath); err == nil { 426 return dir 427 } 428 // Going up! 429 dir += "/.." 430 } 431 } 432 433 func (a *Agent) setupLogging() error { 434 filename := fmt.Sprintf("scope-go-%s-%s.log", time.Now().Format("20060102150405"), a.agentId) 435 dir, err := getLogPath() 436 if err != nil { 437 return err 438 } 439 a.recorderFilename = filepath.Join(dir, filename) 440 441 file, err := os.OpenFile(a.recorderFilename, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666) 442 if err != nil { 443 return err 444 } 445 446 a.logger = log.New(file, "", log.LstdFlags|log.Lshortfile) 447 return nil 448 } 449 450 func (a *Agent) Tracer() opentracing.Tracer { 451 return a.tracer 452 } 453 454 func (a *Agent) Logger() *log.Logger { 455 return a.logger 456 } 457 458 // Runs the test suite 459 func (a *Agent) Run(m *testing.M) int { 460 defer a.Stop() 461 return runner.Run(m, runner.Options{ 462 FailRetries: a.failRetriesCount, 463 PanicAsFail: a.panicAsFail, 464 Logger: a.logger, 465 OnPanic: func(t *testing.T, err interface{}) { 466 if t != nil { 467 a.logger.Printf("test '%s' has panicked (%v), stopping agent", t.Name(), err) 468 } else { 469 a.logger.Printf("panic: %v", err) 470 } 471 a.Stop() 472 }, 473 }) 474 } 475 476 // Stops the agent 477 func (a *Agent) Stop() { 478 a.logger.Println("Scope agent is stopping gracefully...") 479 if a.recorder != nil { 480 a.recorder.Stop() 481 } 482 a.PrintReport() 483 } 484 485 // Flush agent buffer 486 func (a *Agent) Flush() { 487 a.logger.Println("Flushing agent buffer...") 488 if a.recorder != nil { 489 if err := a.recorder.Flush(); err != nil { 490 a.logger.Println(err) 491 } 492 } 493 } 494 495 func generateAgentID() string { 496 agentId, err := uuid.NewRandom() 497 if err != nil { 498 panic(err) 499 } 500 return agentId.String() 501 } 502 503 func getLogPath() (string, error) { 504 if env.ScopeLoggerRoot.IsSet { 505 return env.ScopeLoggerRoot.Value, nil 506 } 507 508 logFolder := "" 509 if runtime.GOOS == "linux" { 510 logFolder = "/var/log/scope" 511 } else { 512 homeDir, err := homedir.Dir() 513 if err != nil { 514 return "", err 515 } 516 if runtime.GOOS == "windows" { 517 logFolder = fmt.Sprintf("%s/AppData/Roaming/scope/logs", homeDir) 518 } else if runtime.GOOS == "darwin" { 519 logFolder = fmt.Sprintf("%s/Library/Logs/Scope", homeDir) 520 } 521 } 522 523 if logFolder != "" { 524 if _, err := os.Stat(logFolder); err == nil { 525 return logFolder, nil 526 } else if os.IsNotExist(err) && os.Mkdir(logFolder, 0755) == nil { 527 return logFolder, nil 528 } 529 } 530 531 // If the log folder can't be used we return a temporal path, so we don't miss the agent logs 532 logFolder = filepath.Join(os.TempDir(), "scope") 533 if _, err := os.Stat(logFolder); err == nil { 534 return logFolder, nil 535 } else if os.IsNotExist(err) && os.Mkdir(logFolder, 0755) == nil { 536 return logFolder, nil 537 } else { 538 return "", err 539 } 540 } 541 542 func parseDSN(dsnString string) (apiKey string, apiEndpoint string, err error) { 543 uri, err := url.Parse(dsnString) 544 if err != nil { 545 return "", "", err 546 } 547 if uri.User != nil { 548 apiKey = uri.User.Username() 549 } 550 uri.User = nil 551 apiEndpoint = uri.String() 552 return 553 } 554 555 func (a *Agent) getUrl(pathValue string) string { 556 uri, err := url.Parse(a.apiEndpoint) 557 if err != nil { 558 a.logger.Fatal(err) 559 } 560 uri.Path = path.Join(uri.Path, pathValue) 561 return uri.String() 562 }