|
| 1 | +// Unless explicitly stated otherwise all files in this repository are licensed |
| 2 | +// under the Apache License Version 2.0. |
| 3 | +// This product includes software developed at Datadog (https://www.datadoghq.com/). |
| 4 | +// Copyright 2025-present Datadog, Inc. |
| 5 | + |
| 6 | +package providers |
| 7 | + |
| 8 | +import ( |
| 9 | + "context" |
| 10 | + "encoding/json" |
| 11 | + "errors" |
| 12 | + "fmt" |
| 13 | + "io" |
| 14 | + "os" |
| 15 | + "sync" |
| 16 | + "unicode/utf8" |
| 17 | + |
| 18 | + "github.com/DataDog/datadog-agent/comp/core/autodiscovery/integration" |
| 19 | + "github.com/DataDog/datadog-agent/comp/core/autodiscovery/providers/names" |
| 20 | + "github.com/DataDog/datadog-agent/comp/core/autodiscovery/providers/types" |
| 21 | + "github.com/DataDog/datadog-agent/comp/core/autodiscovery/telemetry" |
| 22 | + workloadmeta "github.com/DataDog/datadog-agent/comp/core/workloadmeta/def" |
| 23 | + pkgconfigsetup "github.com/DataDog/datadog-agent/pkg/config/setup" |
| 24 | + "github.com/DataDog/datadog-agent/pkg/logs/status" |
| 25 | + "github.com/DataDog/datadog-agent/pkg/util/log" |
| 26 | + "github.com/hashicorp/golang-lru/v2/simplelru" |
| 27 | +) |
| 28 | + |
| 29 | +type serviceLogRef struct { |
| 30 | + refCount int |
| 31 | + config integration.Config |
| 32 | +} |
| 33 | + |
| 34 | +type processLogConfigProvider struct { |
| 35 | + workloadmetaStore workloadmeta.Component |
| 36 | + serviceLogRefs map[string]*serviceLogRef |
| 37 | + pidToServiceIDs map[int32][]string |
| 38 | + unreadableFilesCache *simplelru.LRU[string, struct{}] |
| 39 | + mu sync.RWMutex |
| 40 | +} |
| 41 | + |
| 42 | +var _ types.ConfigProvider = &processLogConfigProvider{} |
| 43 | +var _ types.StreamingConfigProvider = &processLogConfigProvider{} |
| 44 | + |
| 45 | +// NewProcessLogConfigProvider returns a new ConfigProvider subscribed to process events |
| 46 | +func NewProcessLogConfigProvider(_ *pkgconfigsetup.ConfigurationProviders, wmeta workloadmeta.Component, _ *telemetry.Store) (types.ConfigProvider, error) { |
| 47 | + cache, err := simplelru.NewLRU[string, struct{}](128, nil) |
| 48 | + if err != nil { |
| 49 | + return nil, err |
| 50 | + } |
| 51 | + return &processLogConfigProvider{ |
| 52 | + workloadmetaStore: wmeta, |
| 53 | + serviceLogRefs: make(map[string]*serviceLogRef), |
| 54 | + pidToServiceIDs: make(map[int32][]string), |
| 55 | + unreadableFilesCache: cache, |
| 56 | + }, nil |
| 57 | +} |
| 58 | + |
| 59 | +// String returns a string representation of the ProcessLogConfigProvider |
| 60 | +func (p *processLogConfigProvider) String() string { |
| 61 | + return names.ProcessLog |
| 62 | +} |
| 63 | + |
| 64 | +// Stream starts listening to workloadmeta to generate configs as they come |
| 65 | +func (p *processLogConfigProvider) Stream(ctx context.Context) <-chan integration.ConfigChanges { |
| 66 | + outCh := make(chan integration.ConfigChanges, 1) |
| 67 | + |
| 68 | + filter := workloadmeta.NewFilterBuilder(). |
| 69 | + AddKindWithEntityFilter(workloadmeta.KindProcess, func(e workloadmeta.Entity) bool { |
| 70 | + process, ok := e.(*workloadmeta.Process) |
| 71 | + if !ok { |
| 72 | + return false |
| 73 | + } |
| 74 | + |
| 75 | + // Ignore containers since log files inside them usually can't be |
| 76 | + // accessed from here since they are in a different namespace. |
| 77 | + return process.Service != nil && process.ContainerID == "" |
| 78 | + }). |
| 79 | + Build() |
| 80 | + inCh := p.workloadmetaStore.Subscribe("process-log-provider", workloadmeta.ConfigProviderPriority, filter) |
| 81 | + |
| 82 | + go func() { |
| 83 | + for { |
| 84 | + select { |
| 85 | + case <-ctx.Done(): |
| 86 | + p.workloadmetaStore.Unsubscribe(inCh) |
| 87 | + return |
| 88 | + case evBundle, ok := <-inCh: |
| 89 | + if !ok { |
| 90 | + return |
| 91 | + } |
| 92 | + |
| 93 | + evBundle.Acknowledge() |
| 94 | + outCh <- p.processEvents(evBundle) |
| 95 | + } |
| 96 | + } |
| 97 | + }() |
| 98 | + |
| 99 | + return outCh |
| 100 | +} |
| 101 | + |
| 102 | +func (p *processLogConfigProvider) processEvents(evBundle workloadmeta.EventBundle) integration.ConfigChanges { |
| 103 | + return p.processEventsInner(evBundle, true) |
| 104 | +} |
| 105 | + |
| 106 | +func checkFileReadable(logPath string) error { |
| 107 | + file, err := os.Open(logPath) |
| 108 | + if err != nil { |
| 109 | + log.Infof("Discovered log file %s could not be opened: %v", logPath, err) |
| 110 | + return err |
| 111 | + } |
| 112 | + |
| 113 | + defer file.Close() |
| 114 | + |
| 115 | + // Read some bytes from this file and check if it is text to avoid adding |
| 116 | + // binary files |
| 117 | + buf := make([]byte, 128) |
| 118 | + _, err = file.Read(buf) |
| 119 | + if err != nil && err != io.EOF { |
| 120 | + log.Infof("Discovered log file %s could not be read: %v", logPath, err) |
| 121 | + return err |
| 122 | + } |
| 123 | + |
| 124 | + if !utf8.Valid(buf) { |
| 125 | + log.Infof("Discovered log file %s is not a text file", logPath) |
| 126 | + return fmt.Errorf("file is not a text file") |
| 127 | + } |
| 128 | + |
| 129 | + return nil |
| 130 | +} |
| 131 | + |
| 132 | +func (p *processLogConfigProvider) isFileReadable(logPath string) bool { |
| 133 | + if _, found := p.unreadableFilesCache.Get(logPath); found { |
| 134 | + return false |
| 135 | + } |
| 136 | + |
| 137 | + err := checkFileReadable(logPath) |
| 138 | + if err != nil { |
| 139 | + // We want to display permissions errors in the agent status. |
| 140 | + if errors.Is(err, os.ErrPermission) { |
| 141 | + status.AddGlobalWarning(logPath, fmt.Sprintf("Discovered log file %s could not be opened due to lack of permissions", logPath)) |
| 142 | + } |
| 143 | + |
| 144 | + oldestPath, _, _ := p.unreadableFilesCache.GetOldest() |
| 145 | + evicted := p.unreadableFilesCache.Add(logPath, struct{}{}) |
| 146 | + // We don't want to keep the number of warnings growing forever, so |
| 147 | + // only keep warnings for files in our lru. |
| 148 | + if evicted { |
| 149 | + status.RemoveGlobalWarning(oldestPath) |
| 150 | + } |
| 151 | + |
| 152 | + return false |
| 153 | + } |
| 154 | + |
| 155 | + // Remove any existing warning for this file, since it is readable. Note that we won't get here |
| 156 | + // for an existing file until it is evicted from the LRU cache. |
| 157 | + status.RemoveGlobalWarning(logPath) |
| 158 | + |
| 159 | + return true |
| 160 | +} |
| 161 | + |
| 162 | +func (p *processLogConfigProvider) processEventsInner(evBundle workloadmeta.EventBundle, verifyReadable bool) integration.ConfigChanges { |
| 163 | + p.mu.Lock() |
| 164 | + defer p.mu.Unlock() |
| 165 | + |
| 166 | + changes := integration.ConfigChanges{} |
| 167 | + |
| 168 | + for _, event := range evBundle.Events { |
| 169 | + process, ok := event.Entity.(*workloadmeta.Process) |
| 170 | + if !ok { |
| 171 | + continue |
| 172 | + } |
| 173 | + |
| 174 | + switch event.Type { |
| 175 | + case workloadmeta.EventTypeSet: |
| 176 | + // The set of logs monitored by this service may change, so we need |
| 177 | + // to handle deleting existing logs too. First, decrement refcounts |
| 178 | + // for existing service IDs associated with this PID. Any logs still |
| 179 | + // present will get their refcount increased in the loop. |
| 180 | + existingServiceIDs := p.pidToServiceIDs[process.Pid] |
| 181 | + |
| 182 | + for _, serviceLogKey := range existingServiceIDs { |
| 183 | + if ref, exists := p.serviceLogRefs[serviceLogKey]; exists { |
| 184 | + ref.refCount-- |
| 185 | + } |
| 186 | + } |
| 187 | + |
| 188 | + // Clear the existing service IDs for this PID, we will re-add them if this |
| 189 | + // process still has logs. |
| 190 | + delete(p.pidToServiceIDs, process.Pid) |
| 191 | + |
| 192 | + newServiceIDs := []string{} |
| 193 | + for _, logFile := range process.Service.LogFiles { |
| 194 | + newServiceIDs = append(newServiceIDs, logFile) |
| 195 | + |
| 196 | + if ref, exists := p.serviceLogRefs[logFile]; exists { |
| 197 | + ref.refCount++ |
| 198 | + } else { |
| 199 | + if verifyReadable && !p.isFileReadable(logFile) { |
| 200 | + continue |
| 201 | + } |
| 202 | + |
| 203 | + log.Infof("Discovered log file %s", logFile) |
| 204 | + |
| 205 | + // Create new config and reference |
| 206 | + config, err := p.buildConfig(process, logFile) |
| 207 | + if err != nil { |
| 208 | + log.Warnf("could not build log config for process %s and file %s: %v", process.EntityID, logFile, err) |
| 209 | + continue |
| 210 | + } |
| 211 | + |
| 212 | + p.serviceLogRefs[logFile] = &serviceLogRef{ |
| 213 | + refCount: 1, |
| 214 | + config: config, |
| 215 | + } |
| 216 | + |
| 217 | + changes.ScheduleConfig(config) |
| 218 | + } |
| 219 | + } |
| 220 | + |
| 221 | + if len(newServiceIDs) > 0 { |
| 222 | + p.pidToServiceIDs[process.Pid] = newServiceIDs |
| 223 | + } |
| 224 | + |
| 225 | + // Unschedule any logs that are no longer present |
| 226 | + for _, serviceLogKey := range existingServiceIDs { |
| 227 | + if ref, exists := p.serviceLogRefs[serviceLogKey]; exists { |
| 228 | + if ref.refCount <= 0 { |
| 229 | + changes.UnscheduleConfig(ref.config) |
| 230 | + delete(p.serviceLogRefs, serviceLogKey) |
| 231 | + } |
| 232 | + } |
| 233 | + } |
| 234 | + |
| 235 | + case workloadmeta.EventTypeUnset: |
| 236 | + serviceIDs := p.pidToServiceIDs[process.Pid] |
| 237 | + for _, serviceLogKey := range serviceIDs { |
| 238 | + if ref, exists := p.serviceLogRefs[serviceLogKey]; exists { |
| 239 | + ref.refCount-- |
| 240 | + |
| 241 | + if ref.refCount <= 0 { |
| 242 | + changes.UnscheduleConfig(ref.config) |
| 243 | + delete(p.serviceLogRefs, serviceLogKey) |
| 244 | + } |
| 245 | + } |
| 246 | + } |
| 247 | + |
| 248 | + delete(p.pidToServiceIDs, process.Pid) |
| 249 | + } |
| 250 | + } |
| 251 | + |
| 252 | + return changes |
| 253 | +} |
| 254 | + |
| 255 | +// getServiceName returns the name of the service to be used in the log config. |
| 256 | +func getServiceName(service *workloadmeta.Service) string { |
| 257 | + if len(service.TracerMetadata) > 0 { |
| 258 | + return service.TracerMetadata[0].ServiceName |
| 259 | + } |
| 260 | + |
| 261 | + if service.DDService != "" { |
| 262 | + return service.DDService |
| 263 | + } |
| 264 | + |
| 265 | + return service.GeneratedName |
| 266 | +} |
| 267 | + |
| 268 | +// getSource returns the source to be used in the log config. This needs to |
| 269 | +// match the integration pipelines, see |
| 270 | +// https://app.datadoghq.com/logs/pipelines/pipeline/library. For now, this has |
| 271 | +// some handling for some common cases, until a better solution is available. |
| 272 | +func getSource(service *workloadmeta.Service) string { |
| 273 | + source := service.GeneratedName |
| 274 | + |
| 275 | + // Binary name differs from the integration name |
| 276 | + if source == "apache2" { |
| 277 | + return "apache" |
| 278 | + } |
| 279 | + if source == "postgres" { |
| 280 | + return "postgresql" |
| 281 | + } |
| 282 | + if source == "catalina" { |
| 283 | + return "tomcat" |
| 284 | + } |
| 285 | + |
| 286 | + // The generated name may be the WSGI application name |
| 287 | + if service.GeneratedNameSource == "gunicorn" { |
| 288 | + return "gunicorn" |
| 289 | + } |
| 290 | + |
| 291 | + return source |
| 292 | +} |
| 293 | + |
| 294 | +func getIntegrationName(logFile string) string { |
| 295 | + return fmt.Sprintf("%s:%s", names.ProcessLog, logFile) |
| 296 | +} |
| 297 | + |
| 298 | +func getServiceID(logFile string) string { |
| 299 | + return fmt.Sprintf("%s://%s", names.ProcessLog, logFile) |
| 300 | +} |
| 301 | + |
| 302 | +func (p *processLogConfigProvider) buildConfig(process *workloadmeta.Process, logFile string) (integration.Config, error) { |
| 303 | + name := getServiceName(process.Service) |
| 304 | + source := getSource(process.Service) |
| 305 | + |
| 306 | + logConfig := map[string]interface{}{ |
| 307 | + "type": "file", |
| 308 | + "path": logFile, |
| 309 | + "service": name, |
| 310 | + "source": source, |
| 311 | + } |
| 312 | + |
| 313 | + data, err := json.Marshal([]map[string]interface{}{logConfig}) |
| 314 | + if err != nil { |
| 315 | + return integration.Config{}, fmt.Errorf("could not marshal log config: %w", err) |
| 316 | + } |
| 317 | + |
| 318 | + integrationName := getIntegrationName(logFile) |
| 319 | + return integration.Config{ |
| 320 | + Name: integrationName, |
| 321 | + LogsConfig: data, |
| 322 | + Provider: names.ProcessLog, |
| 323 | + Source: integrationName, |
| 324 | + ServiceID: getServiceID(logFile), |
| 325 | + }, nil |
| 326 | +} |
| 327 | + |
| 328 | +// GetConfigErrors returns a map of configuration errors, which is always empty for this provider. |
| 329 | +func (p *processLogConfigProvider) GetConfigErrors() map[string]types.ErrorMsgSet { |
| 330 | + return make(map[string]types.ErrorMsgSet) |
| 331 | +} |
0 commit comments