Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ source files for evidence.
embedded `charts.yaml` is RECOMMENDED.
- `Collect(ctx)` MUST return `error` and write metrics to `metrix`; it MUST NOT
return a V1 `map[string]int64`.
- Long-running side-effect loops that must start only with the running job MAY
implement optional `collectorapi.CollectorV2Runner`. `Run(ctx)` MUST return
promptly after cancellation. Do not start operational polling from `Init()` or
`Check()`, because DynCfg `test` and autodetection use those methods without
starting the runtime job.
- Collector `Cleanup(ctx)` MUST be idempotent. The framework may call it more
than once, including after partial `Init` / `Check` setup.
- Files SHOULD stay boring: public lifecycle methods in `collector.go`, setup
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ You can control what functionalities users can access in Netdata Cloud through t
| **View-only access** - monitor specific systems without making changes | **Observer** |
| **Billing management** - handle invoices and payments without system access | **Billing** |

## Role Change Propagation

Role changes take effect immediately. When an Admin or Manager changes a user's role, the updated permissions are applied right away by the Netdata Cloud backend.

## Quick Reference

<details>
Expand Down
8 changes: 7 additions & 1 deletion src/collectors/statsd.plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -496,7 +496,13 @@ For example, to monitor the application `myapp` using StatsD and Netdata, create

Using this configuration, `myapp` gets its own dashboard section with one chart containing two [dimensions](https://learn.netdata.cloud/docs/developer-and-contributor-corner/glossary#d).

When you send metrics like `foo:10|g` and `bar:20|g`, you'll see both private charts and your synthetic chart.
When you send metrics like `myapp.metric1:10|g` and `myapp.metric2:20|g`, you'll see both private charts and your synthetic chart. These metric names must match the pattern defined in the `[app]` section (e.g., `myapp.*`) for them to appear in your synthetic charts.

:::note

**Synthetic chart appears empty or is missing?** This happens when the metric names you send don't match the `metrics` pattern in your `[app]` section. StatsD matches incoming metric names against the `metrics` pattern using Netdata's [simple pattern](/src/libnetdata/simple_pattern/README.md) syntax — if a metric name doesn't match, it is never linked to the app's synthetic charts. For example, with `metrics = myapp.*`, sending bare names like `foo:10|g` creates a private chart for `foo` but never feeds the synthetic chart. To fix this, send metric names that include the prefix matching the pattern (e.g., `myapp.foo:10|g`).

:::

<details>
<summary><strong>Synthetic Chart Example</strong></summary>
Expand Down
2 changes: 1 addition & 1 deletion src/database/contexts/rrdcontext.c
Original file line number Diff line number Diff line change
Expand Up @@ -324,7 +324,7 @@ static void rrdcontext_checkpoint_execute(RRDHOST *host, const char *claim_id, c
return;

if(rrdhost_flag_check(host, RRDHOST_FLAG_ACLK_STREAM_CONTEXTS)) {
nd_log(NDLS_DAEMON, NDLP_NOTICE,
nd_log(NDLS_DAEMON, NDLP_DEBUG,
"RRDCONTEXT: checkpoint for claim id '%s', node id '%s', "
"while node '%s' has an active context streaming.",
claim_id, node_id, rrdhost_hostname(host));
Expand Down
5 changes: 2 additions & 3 deletions src/go/pkg/funcapi/response.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,8 @@ type MethodConfig struct {
// RawRequest routes the complete Function request to a RawMethodHandler.
// Use this for Function APIs that need raw payloads, args, or full response envelopes.
RawRequest bool
// FIXME: AgentWide currently removes __job from the public API, but funcctl still
// dispatches through the first running job for the module instead of a true
// agent-level execution path.
// AgentWide marks a module/static method as agent-level: funcctl omits
// __job from the public API and dispatches the method without a RuntimeJob.
AgentWide bool // Method is agent-wide (does not require __job selector)
RequiredParams []ParamConfig // Required parameters for this method (including __sort if used)
// FIXME: Presentation is intentionally untyped here, while the shared UI schema
Expand Down
69 changes: 69 additions & 0 deletions src/go/plugin/agent/jobmgr/dyncfg_collector_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"errors"
"os"
"strings"
"sync/atomic"
"testing"
"time"

Expand Down Expand Up @@ -53,6 +54,11 @@ type jobNameRequiredV2Collector struct {
store metrix.CollectorStore
}

type runnerProbeV2Collector struct {
*jobNameRequiredV2Collector
runCalled atomic.Bool
}

func newJobNameRequiredV2Collector() *jobNameRequiredV2Collector {
return &jobNameRequiredV2Collector{
store: metrix.NewCollectorStore(),
Expand All @@ -79,6 +85,11 @@ func (c *jobNameRequiredV2Collector) MetricStore() metrix.CollectorStore {
}
func (c *jobNameRequiredV2Collector) ChartTemplateYAML() string { return "" }

func (c *runnerProbeV2Collector) Run(context.Context) error {
c.runCalled.Store(true)
return nil
}

func TestDyncfgConfigUserconfig_InvalidPayload_Returns400Only(t *testing.T) {
tests := map[string]struct {
contentType string
Expand Down Expand Up @@ -210,6 +221,64 @@ func TestDyncfgCmdTest_PassesJobNameToV2Collector(t *testing.T) {
assert.Equal(t, float64(200), resp["status"])
}

func TestDyncfgCmdTest_DoesNotRunV2CollectorRunner(t *testing.T) {
var buf bytes.Buffer
probe := &runnerProbeV2Collector{jobNameRequiredV2Collector: newJobNameRequiredV2Collector()}

mgr := newCollectorTestManager()
mgr.SetDyncfgResponder(dyncfg.NewResponder(netdataapi.New(safewriter.New(&buf))))
mgr.modules.Register("runnerprobe", collectorapi.Creator{
CreateV2: func() collectorapi.CollectorV2 {
return probe
},
})

fn := dyncfg.NewFunction(functions.Function{
UID: "test-v2-runner",
ContentType: "application/json",
Payload: mustMarshalCollectorConfigPayload(t, prepareDyncfgCfg("runnerprobe", "payload-name")),
Args: []string{mgr.dyncfgModID("runnerprobe"), string(dyncfg.CommandTest), "tested-job"},
})

mgr.dyncfgCmdTest(fn)
mgr.cmdTestWG.Wait()

var resp map[string]any
mustDecodeFunctionPayload(t, buf.String(), "test-v2-runner", &resp)
assert.Equal(t, float64(200), resp["status"])
require.Never(t, probe.runCalled.Load, 100*time.Millisecond, 10*time.Millisecond, "runner started during dyncfg test")
}

func TestDyncfgCmdTest_SingleInstanceV2RunnerUsesCanonicalName(t *testing.T) {
var buf bytes.Buffer
probe := &runnerProbeV2Collector{jobNameRequiredV2Collector: newJobNameRequiredV2Collector()}

mgr := newCollectorTestManager()
mgr.SetDyncfgResponder(dyncfg.NewResponder(netdataapi.New(safewriter.New(&buf))))
mgr.modules.Register("singlev2runner", collectorapi.Creator{
InstancePolicy: collectorapi.InstancePolicySingle,
CreateV2: func() collectorapi.CollectorV2 {
return probe
},
})

fn := dyncfg.NewFunction(functions.Function{
UID: "single-v2-runner-test",
ContentType: "application/json",
Payload: mustMarshalCollectorConfigPayload(t, prepareDyncfgCfg("singlev2runner", "payload-name")),
Args: collectorTestArgs(mgr, "singlev2runner", string(dyncfg.CommandTest)),
})

mgr.dyncfgCmdTest(fn)
mgr.cmdTestWG.Wait()

var resp map[string]any
mustDecodeFunctionPayload(t, buf.String(), "single-v2-runner-test", &resp)
assert.Equal(t, float64(200), resp["status"])
assert.Equal(t, "singlev2runner", probe.jobName)
require.Never(t, probe.runCalled.Load, 100*time.Millisecond, 10*time.Millisecond, "runner started during single-instance dyncfg test")
}

func TestDyncfgCmdTest_SingleInstancePolicyUsesCanonicalName(t *testing.T) {
var buf bytes.Buffer
mod := &namedTestV1Module{}
Expand Down
10 changes: 9 additions & 1 deletion src/go/plugin/agent/jobmgr/funcctl/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ func (c *Controller) RegisterModules(modules collectorapi.Registry) {
continue
}
c.registry.registerModuleWithMethods(name, creator, methods)
c.registerAvailableModuleMethods(name, methods, true)
}
}

Expand Down Expand Up @@ -167,7 +168,14 @@ func (c *Controller) registerModuleMethodsOnJobStart(moduleName string) {
return
}

for _, method := range creator.Methods() {
c.registerAvailableModuleMethods(moduleName, creator.Methods(), false)
}

func (c *Controller) registerAvailableModuleMethods(moduleName string, methods []funcapi.MethodConfig, agentWideOnly bool) {
for _, method := range methods {
if agentWideOnly && !method.AgentWide {
continue
}
if !methodAvailable(method) {
continue
}
Expand Down
115 changes: 100 additions & 15 deletions src/go/plugin/agent/jobmgr/funcctl/controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -411,17 +411,19 @@ func TestParseArgsParams(t *testing.T) {

func TestControllerLifecycleHooks(t *testing.T) {
tests := map[string]struct{}{
"register modules does not register static methods yet": {},
"first job start registers static methods once": {},
"availability-gated static method registers when available": {},
"public method name collision skips colliding module": {},
"rejected module does not poison planned public names": {},
"topology methods register direct alias": {},
"job stop unregisters job methods": {},
"cleanup unregisters static methods": {},
"cleanup ignores unavailable static methods": {},
"cleanup with api configured still unregisters static methods": {},
"api registration honors method tags": {},
"register modules does not register static methods yet": {},
"register modules registers available agent-wide methods": {},
"first job start registers static methods once": {},
"availability-gated static method registers when available": {},
"availability-gated agent-wide method registers when available": {},
"public method name collision skips colliding module": {},
"rejected module does not poison planned public names": {},
"topology methods register direct alias": {},
"job stop unregisters job methods": {},
"cleanup unregisters static methods": {},
"cleanup ignores unavailable static methods": {},
"cleanup with api configured still unregisters static methods": {},
"api registration honors method tags": {},
}

for name := range tests {
Expand All @@ -439,6 +441,17 @@ func TestControllerLifecycleHooks(t *testing.T) {

assert.Empty(t, reg.registeredNames())

case "register modules registers available agent-wide methods":
controller.RegisterModules(collectorapi.Registry{
"mod": collectorapi.Creator{
Methods: func() []funcapi.MethodConfig {
return []funcapi.MethodConfig{{ID: "logs", AgentWide: true}}
},
},
})

assert.Equal(t, []string{"mod:logs"}, reg.registeredNames())

case "first job start registers static methods once":
controller.RegisterModules(collectorapi.Registry{
"mod": collectorapi.Creator{
Expand Down Expand Up @@ -473,6 +486,28 @@ func TestControllerLifecycleHooks(t *testing.T) {

assert.Equal(t, []string{"mod:logs"}, reg.registeredNames())

case "availability-gated agent-wide method registers when available":
available := false
controller.RegisterModules(collectorapi.Registry{
"mod": collectorapi.Creator{
Methods: func() []funcapi.MethodConfig {
return []funcapi.MethodConfig{{
ID: "logs",
AgentWide: true,
Available: func() bool { return available },
}}
},
},
})

assert.Empty(t, reg.registeredNames())

available = true
controller.OnJobStart(newTestRuntimeJob("mod", "job1", true))
controller.OnJobStart(newTestRuntimeJob("mod", "job2", true))

assert.Equal(t, []string{"mod:logs"}, reg.registeredNames())

case "public method name collision skips colliding module":
controller.RegisterModules(collectorapi.Registry{
"aaa": collectorapi.Creator{
Expand Down Expand Up @@ -520,15 +555,18 @@ func TestControllerLifecycleHooks(t *testing.T) {

case "topology methods register direct alias":
controller.RegisterModules(collectorapi.Registry{
"snmp": collectorapi.Creator{
"snmp_topology": collectorapi.Creator{
Methods: func() []funcapi.MethodConfig {
return []funcapi.MethodConfig{{ID: "topology:snmp", Aliases: []string{"topology:snmp"}}}
return []funcapi.MethodConfig{{
ID: "topology:snmp",
FunctionName: "snmp:topology:snmp",
Aliases: []string{"topology:snmp"},
AgentWide: true,
}}
},
},
})

controller.OnJobStart(newTestRuntimeJob("snmp", "edge-router", true))

assert.ElementsMatch(t, []string{"snmp:topology:snmp", "topology:snmp"}, reg.registeredNames())

controller.Cleanup()
Expand Down Expand Up @@ -853,6 +891,53 @@ func TestControllerRawModuleMethodRequest(t *testing.T) {
}
}

func TestControllerRawAgentWideModuleMethodDoesNotRequireRunningJob(t *testing.T) {
var gotCode int
var gotResp map[string]any
var gotJob collectorapi.RuntimeJob
reg := newTestFunctionRegistry()
controller := New(Options{
FnReg: reg,
JSONWriter: func(data []byte, code int) {
gotCode = code
require.NoError(t, json.Unmarshal(data, &gotResp))
},
})

controller.RegisterModules(collectorapi.Registry{
"mod": collectorapi.Creator{
Methods: func() []funcapi.MethodConfig {
return []funcapi.MethodConfig{{
ID: "logs",
RawRequest: true,
AgentWide: true,
}}
},
MethodHandler: func(job collectorapi.RuntimeJob) funcapi.MethodHandler {
gotJob = job
return &rawTestHandler{
raw: func(_ context.Context, req funcapi.RawMethodRequest) *funcapi.FunctionResponse {
assert.Equal(t, "logs", req.Method)
return funcapi.RawResponse(map[string]any{
"status": 200,
"type": "table",
})
},
}
},
},
})

reg.call("mod:logs", context.Background(), functions.Function{
UID: "raw-agent-wide",
Timeout: time.Second,
})

assert.Equal(t, 200, gotCode)
assert.Equal(t, float64(200), gotResp["status"])
assert.Nil(t, gotJob)
}

func TestControllerModuleMethodRequestContextCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
Expand Down
Loading
Loading