Skip to content

Commit 2b52f4b

Browse files
vitkyrkafrank-spano
authored andcommitted
Add log discovery based on service information in WLM (#38006)
1 parent 6cff215 commit 2b52f4b

8 files changed

Lines changed: 1393 additions & 3 deletions

File tree

.github/CODEOWNERS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,7 @@
404404
/comp/core/autodiscovery/providers/file*.go @DataDog/agent-log-pipelines
405405
/comp/core/autodiscovery/providers/config_reader*.go @DataDog/container-platform @DataDog/agent-log-pipelines
406406
/comp/core/autodiscovery/providers/cloudfoundry*.go @DataDog/agent-integrations
407+
/comp/core/autodiscovery/providers/process_log*.go @DataDog/agent-discovery @DataDog/agent-log-pipelines
407408
/comp/core/autodiscovery/providers/remote_config*.go @DataDog/remote-config
408409
/pkg/cloudfoundry @Datadog/agent-integrations
409410
/pkg/clusteragent/ @DataDog/container-platform

.golangci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,9 @@ issues:
179179
- path: comp/core/autodiscovery/providers/kube_services_nop.go
180180
linters:
181181
- pkgconfigusage
182+
- path: comp/core/autodiscovery/providers/process_log.go
183+
linters:
184+
- pkgconfigusage
182185
- path: comp/core/autodiscovery/providers/prometheus_common.go
183186
linters:
184187
- pkgconfigusage

comp/core/autodiscovery/providers/names/provider_names.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ const (
2222
KubeServicesFile = "kubernetes-services-file"
2323
KubeEndpoints = "kubernetes-endpoints"
2424
KubeEndpointsFile = "kubernetes-endpoints-file"
25+
ProcessLog = "process_log"
2526
PrometheusPods = "prometheus-pods"
2627
PrometheusServices = "prometheus-services"
2728
RemoteConfig = "remote-config"
Lines changed: 331 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,331 @@
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

Comments
 (0)