@deliciousmonster/datadog-agent-binary 7.82.1-next.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +138 -0
- package/conf.d/cpu.d/conf.yaml.default +11 -0
- package/conf.d/disk.d/conf.yaml.default +15 -0
- package/conf.d/file_handle.d/conf.yaml.default +11 -0
- package/conf.d/io.d/conf.yaml.default +12 -0
- package/conf.d/load.d/conf.yaml.default +6 -0
- package/conf.d/load.d/platforms +4 -0
- package/conf.d/memory.d/conf.yaml.default +10 -0
- package/conf.d/network.d/conf.yaml.default +10 -0
- package/conf.d/network.d/platforms +4 -0
- package/conf.d/uptime.d/conf.yaml.default +6 -0
- package/config.yaml +11 -0
- package/package.json +81 -0
- package/resources.js +288 -0
- package/runtime/agent-exit.js +60 -0
- package/runtime/binary.js +61 -0
- package/runtime/config.js +195 -0
- package/runtime/delivery.js +137 -0
- package/runtime/probe.js +115 -0
- package/runtime/supervisor.js +172 -0
- package/runtime/verify.js +190 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// Whether anything this node collected reached Datadog. Every counter here is a one-minute window the agent
|
|
2
|
+
// resets, so nothing is cumulative and nothing diffs.
|
|
3
|
+
|
|
4
|
+
import { parseJson, pollEndpoint } from "./probe.js";
|
|
5
|
+
|
|
6
|
+
// The trace-agent's own expvar, and https because it serves it under the self-signed IPC certificate.
|
|
7
|
+
// Stated once so the URL that is read and the `source` that is reported can never be different ports or schemes.
|
|
8
|
+
export const debugVarsUrl = (port) => `https://127.0.0.1:${port}/debug/vars`;
|
|
9
|
+
|
|
10
|
+
// Carried in the payload and bumped when the shape or the verdict set changes, so a consumer written against
|
|
11
|
+
// an older one can tell rather than guess.
|
|
12
|
+
const DELIVERY_SIGNAL_VERSION = 1;
|
|
13
|
+
|
|
14
|
+
const unavailable = (source, detail) => ({
|
|
15
|
+
source,
|
|
16
|
+
signalVersion: DELIVERY_SIGNAL_VERSION,
|
|
17
|
+
verdict: "unavailable",
|
|
18
|
+
detail,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Three hops read apart, because proving one proves nothing about the next: spans into the local receiver,
|
|
23
|
+
* APM stats into Datadog, trace payloads into Datadog.
|
|
24
|
+
*/
|
|
25
|
+
export function deliveryVerdict(vars, source) {
|
|
26
|
+
const clients = Array.isArray(vars?.receiver) ? vars.receiver : [];
|
|
27
|
+
const sum = (field) =>
|
|
28
|
+
clients.reduce((total, entry) => total + (Number(entry?.[field]) || 0), 0);
|
|
29
|
+
const count = (writer, field) => Number(vars?.[writer]?.[field]) || 0;
|
|
30
|
+
|
|
31
|
+
const receiver = {
|
|
32
|
+
tracesReceived: sum("TracesReceived"),
|
|
33
|
+
spansReceived: sum("SpansReceived"),
|
|
34
|
+
clients: clients.map(
|
|
35
|
+
(entry) => `${entry?.Lang ?? "?"} ${entry?.TracerVersion ?? "?"}`
|
|
36
|
+
),
|
|
37
|
+
};
|
|
38
|
+
const statsWriter = {
|
|
39
|
+
payloads: count("stats_writer", "Payloads"),
|
|
40
|
+
errors: count("stats_writer", "Errors"),
|
|
41
|
+
retries: count("stats_writer", "Retries"),
|
|
42
|
+
buckets: count("stats_writer", "StatsBuckets"),
|
|
43
|
+
clientPayloads: count("stats_writer", "ClientPayloads"),
|
|
44
|
+
};
|
|
45
|
+
const traceWriter = {
|
|
46
|
+
payloads: count("trace_writer", "Payloads"),
|
|
47
|
+
bytes: count("trace_writer", "Bytes"),
|
|
48
|
+
errors: count("trace_writer", "Errors"),
|
|
49
|
+
retries: count("trace_writer", "Retries"),
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const arriving = receiver.tracesReceived > 0 || receiver.spansReceived > 0;
|
|
53
|
+
// The receiver snapshot is refreshed only when a payload arrives, so on a quiet node it is the last busy
|
|
54
|
+
// minute. The concentrator builds buckets from received spans before anything is sent, so these date it.
|
|
55
|
+
const thisMinute = statsWriter.buckets > 0 || statsWriter.clientPayloads > 0;
|
|
56
|
+
const statsRefused = statsWriter.errors > 0 || statsWriter.retries > 0;
|
|
57
|
+
// Read asymmetrically: two writers register into the one trace_writer expvar slot and the last one wins,
|
|
58
|
+
// so a non-zero came from whichever did the work and is evidence, while a zero carries nothing at all.
|
|
59
|
+
const tracesRefused = traceWriter.errors > 0 || traceWriter.retries > 0;
|
|
60
|
+
|
|
61
|
+
const signal = {
|
|
62
|
+
source,
|
|
63
|
+
signalVersion: DELIVERY_SIGNAL_VERSION,
|
|
64
|
+
agentVersion: vars?.version?.Version,
|
|
65
|
+
receiver,
|
|
66
|
+
statsWriter,
|
|
67
|
+
traceWriter,
|
|
68
|
+
// true is evidence the hop works, false evidence it does not, null no evidence either way.
|
|
69
|
+
proven: {
|
|
70
|
+
tracesAtReceiver: arriving ? true : null,
|
|
71
|
+
statsAtDatadog:
|
|
72
|
+
statsWriter.payloads > 0 ? true : statsRefused ? false : null,
|
|
73
|
+
tracesAtDatadog:
|
|
74
|
+
traceWriter.payloads > 0 ? true : tracesRefused ? false : null,
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
if (traceWriter.payloads > 0) {
|
|
79
|
+
signal.verdict = "delivering";
|
|
80
|
+
signal.detail = `the intake took ${traceWriter.payloads} trace payload(s), ${traceWriter.bytes} bytes, in the last minute.`;
|
|
81
|
+
} else if (tracesRefused) {
|
|
82
|
+
signal.verdict = "rejected";
|
|
83
|
+
signal.detail = `every trace payload sent in the last minute came back refused (${traceWriter.retries} retries, ${traceWriter.errors} errors) and none were accepted. Check DD_API_KEY and DD_SITE.`;
|
|
84
|
+
} else if (statsWriter.payloads > 0) {
|
|
85
|
+
signal.verdict = "traces-unconfirmed";
|
|
86
|
+
signal.detail =
|
|
87
|
+
`the intake accepted ${statsWriter.payloads} APM stats payload(s) in the last minute, so the key, ` +
|
|
88
|
+
`the site and the route out are good. Trace payloads are a separate hop on that route and nothing ` +
|
|
89
|
+
`here proves one landed: trace_writer is zero, and on this agent build a zero is the shared expvar ` +
|
|
90
|
+
`slot rather than a measurement. Confirm in the Datadog trace explorer.`;
|
|
91
|
+
} else if (statsRefused) {
|
|
92
|
+
signal.verdict = "rejected";
|
|
93
|
+
signal.detail = `the intake refused every APM stats payload in the last minute (${statsWriter.retries} retries, ${statsWriter.errors} errors) and accepted none. Check DD_API_KEY and DD_SITE.`;
|
|
94
|
+
} else if (arriving && thisMinute) {
|
|
95
|
+
signal.verdict = "not-delivering";
|
|
96
|
+
signal.detail = `${receiver.spansReceived} spans reached the receiver this minute and neither the stats hop nor the traces hop has had anything accepted. Both windows reset every minute; read this again before believing it.`;
|
|
97
|
+
} else if (arriving) {
|
|
98
|
+
signal.verdict = "idle";
|
|
99
|
+
signal.detail = `the receiver still reports ${receiver.spansReceived} spans while the stats writer saw no work at all, so that snapshot is left over from an earlier minute and nothing arrived in this one.`;
|
|
100
|
+
} else {
|
|
101
|
+
signal.verdict = "idle";
|
|
102
|
+
signal.detail =
|
|
103
|
+
"no spans reached the trace-agent in the last minute. Send the application some traffic and read this again; nothing here separates a quiet node from a dead tracer.";
|
|
104
|
+
}
|
|
105
|
+
return signal;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The trace-agent's own delivery counters. Never rejects: an endpoint that does not answer is itself a verdict. */
|
|
109
|
+
export async function readDeliverySignal(port) {
|
|
110
|
+
const source = debugVarsUrl(port);
|
|
111
|
+
if (port === 0) {
|
|
112
|
+
return unavailable(
|
|
113
|
+
source,
|
|
114
|
+
"apm_config.debug.port is 0 (DD_APM_DEBUG_PORT), so the trace-agent publishes no expvar to read."
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
// One attempt, because a status endpoint answers now or reports that nothing did; waiting out a bind is
|
|
118
|
+
// what the start-up verifies already do.
|
|
119
|
+
const body = await pollEndpoint({
|
|
120
|
+
url: source,
|
|
121
|
+
timeoutMs: 1000,
|
|
122
|
+
giveUp: () => true,
|
|
123
|
+
});
|
|
124
|
+
if (body === null) {
|
|
125
|
+
return unavailable(
|
|
126
|
+
source,
|
|
127
|
+
`nothing answered ${source}, so no trace-agent is running on this node, or the one that is does not serve apm_config.debug.port.`
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
const vars = parseJson(body);
|
|
131
|
+
return vars === null
|
|
132
|
+
? unavailable(
|
|
133
|
+
source,
|
|
134
|
+
`${source} answered with something that is not expvar JSON.`
|
|
135
|
+
)
|
|
136
|
+
: deliveryVerdict(vars, source);
|
|
137
|
+
}
|
package/runtime/probe.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// Every request this module makes is invisible to APM: the plugin polls the agents before they bind, when
|
|
2
|
+
// polls fail, and on the line this replaces those failures became errored client spans on the customer's own service.
|
|
3
|
+
|
|
4
|
+
import { request as httpRequest } from "node:http";
|
|
5
|
+
import { request as httpsRequest } from "node:https";
|
|
6
|
+
import { createRequire } from "node:module";
|
|
7
|
+
|
|
8
|
+
const PROBE_TIMEOUT_MS = 1000;
|
|
9
|
+
const MAX_INTERVAL_MS = 5_000;
|
|
10
|
+
|
|
11
|
+
// The store dd-trace keeps its OWN agent traffic out of the customer's APM with, applied to these probes for
|
|
12
|
+
// the same reason. Private path, so a miss falls back to untraceAgentProbes, which is the public half of this.
|
|
13
|
+
const untraced = (() => {
|
|
14
|
+
try {
|
|
15
|
+
const core = createRequire(import.meta.url)(
|
|
16
|
+
"dd-trace/packages/datadog-core"
|
|
17
|
+
);
|
|
18
|
+
const legacy = core.storage("legacy");
|
|
19
|
+
return (run) => legacy.run({ noop: true }, run);
|
|
20
|
+
} catch {
|
|
21
|
+
return (run) => run();
|
|
22
|
+
}
|
|
23
|
+
})();
|
|
24
|
+
|
|
25
|
+
// A process-global setting, so the next `tracer.use('http', ...)` from anywhere replaces it: dd-trace's
|
|
26
|
+
// configurePlugin overwrites a plugin's config rather than merging into it. Kept only as a fallback for releases where the private store above has moved; it cannot stand alone.
|
|
27
|
+
export function untraceAgentProbes(tracer, blocklist) {
|
|
28
|
+
tracer.use("http", { client: { blocklist } }); // under `client`, or the server half drops inbound traces too
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A probe body as JSON, or null. Never throws: these bodies come off a socket and pollEndpoint's own contract is the same. */
|
|
32
|
+
export function parseJson(body) {
|
|
33
|
+
try {
|
|
34
|
+
return body === null ? null : JSON.parse(body);
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** GET over http or https, accepting the trace-agent's self-signed IPC certificate. Loopback only: never point this off 127.0.0.1. Global fetch cannot stand in for it, because Node exposes no public dispatcher for that certificate. */
|
|
41
|
+
function get(url, timeoutMs) {
|
|
42
|
+
return new Promise((resolve) => {
|
|
43
|
+
let deadline;
|
|
44
|
+
const settle = (body) => {
|
|
45
|
+
clearTimeout(deadline);
|
|
46
|
+
resolve(body);
|
|
47
|
+
};
|
|
48
|
+
// node:http ignores rejectUnauthorized, so the scheme is the only difference between the two probes.
|
|
49
|
+
const send = url.startsWith("https:") ? httpsRequest : httpRequest;
|
|
50
|
+
const call = send(url, { rejectUnauthorized: false }, (response) => {
|
|
51
|
+
const status = response.statusCode;
|
|
52
|
+
if (status === undefined || status < 200 || status >= 300) {
|
|
53
|
+
response.resume();
|
|
54
|
+
settle(null);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
let body = "";
|
|
58
|
+
response.setEncoding("utf-8");
|
|
59
|
+
response.on("data", (chunk) => (body += chunk));
|
|
60
|
+
response.on("end", () => settle(body));
|
|
61
|
+
// The reset a destroy() lands on an open response arrives here, not on the request, and an
|
|
62
|
+
// unheard one leaves this promise pending for the life of the process.
|
|
63
|
+
response.on("error", () => settle(null));
|
|
64
|
+
});
|
|
65
|
+
call.on("error", () => settle(null));
|
|
66
|
+
// One deadline over the whole exchange rather than the socket's own inactivity timeout: a response
|
|
67
|
+
// that starts and then stalls, or drips a byte at a time, never trips that one.
|
|
68
|
+
deadline = setTimeout(() => {
|
|
69
|
+
call.destroy();
|
|
70
|
+
settle(null);
|
|
71
|
+
}, timeoutMs);
|
|
72
|
+
call.end();
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Run under `untraced`: the span is created where the request is made, so that is the only place suppression
|
|
77
|
+
// cannot be undone by other code in the process.
|
|
78
|
+
async function probe(url, timeoutMs) {
|
|
79
|
+
try {
|
|
80
|
+
// Awaited inside the try rather than returned: `untraced` reaches into a dd-trace private path, and
|
|
81
|
+
// pollEndpoint's never-throws contract has to hold if that path moves.
|
|
82
|
+
return await untraced(() => get(url, timeoutMs));
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* GET until something answers: the body text, or null on deadline or giveUp(). Never throws. `intervalMs`
|
|
90
|
+
* doubles every retry up to MAX_INTERVAL_MS: a flat 250ms wait costs ~120 probes over a ~6-7s expvar bind.
|
|
91
|
+
*/
|
|
92
|
+
export async function pollEndpoint({
|
|
93
|
+
url,
|
|
94
|
+
timeoutMs = 30_000,
|
|
95
|
+
intervalMs = 250,
|
|
96
|
+
giveUp,
|
|
97
|
+
}) {
|
|
98
|
+
const deadline = Date.now() + timeoutMs;
|
|
99
|
+
let interval = intervalMs;
|
|
100
|
+
for (;;) {
|
|
101
|
+
const budget = Math.min(
|
|
102
|
+
PROBE_TIMEOUT_MS,
|
|
103
|
+
Math.max(deadline - Date.now(), 1)
|
|
104
|
+
);
|
|
105
|
+
const body = await probe(url, budget);
|
|
106
|
+
if (body !== null) return body;
|
|
107
|
+
// Asked between probes, and only after one has failed, so a target that answered then died still counts.
|
|
108
|
+
if (giveUp?.() || Date.now() >= deadline) return null;
|
|
109
|
+
// Clamped to what is left: backing off must not spend the caller's budget asleep past the deadline.
|
|
110
|
+
await new Promise((resolve) =>
|
|
111
|
+
setTimeout(resolve, Math.min(interval, deadline - Date.now()))
|
|
112
|
+
);
|
|
113
|
+
interval = Math.min(interval * 2, MAX_INTERVAL_MS);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// Who holds the agents up. Released Harper has no sidecar API, so the bundled guard is the other half; one
|
|
2
|
+
// agent per node comes from a PID lock either way and only the holder changes.
|
|
3
|
+
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
// Pinned to one exact version, never a range: Harper runs `npm install` when it installs a component, so a
|
|
7
|
+
// caret here would let a customer's node resolve a guard no test in this repo has run against.
|
|
8
|
+
import { fingerprint, guard } from "@deliciousmonster/harper-process-guard";
|
|
9
|
+
import { describeSpawnFailure } from "./agent-exit.js";
|
|
10
|
+
import { writeConfigFiles } from "./config.js";
|
|
11
|
+
|
|
12
|
+
// No released Harper has `scope.processes` - harper@5.2.9 is latest and its Scope carries no such member - so
|
|
13
|
+
// this answers false on every node a customer can run today, and the guard below is the only shipping path.
|
|
14
|
+
const supervisesNatively = (scope) =>
|
|
15
|
+
typeof scope?.processes?.start === "function";
|
|
16
|
+
|
|
17
|
+
// The state a supervisor never reached, in the shape both of them report. `started` is what answers "is it
|
|
18
|
+
// running": `exited: false` here means it never ran, not that it still does.
|
|
19
|
+
export const unstarted = (agent, error) => ({
|
|
20
|
+
name: agent.name,
|
|
21
|
+
title: agent.title,
|
|
22
|
+
kind: agent.kind,
|
|
23
|
+
started: false,
|
|
24
|
+
adopted: false,
|
|
25
|
+
exited: false,
|
|
26
|
+
restarts: 0,
|
|
27
|
+
error,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// What the NATIVE path publishes about the reaper, where the object comes from a Harper this package does not
|
|
31
|
+
// ship and may carry anything. The guard's own reaper state is a documented shape and is published whole.
|
|
32
|
+
const REAPER_FIELDS = ["name", "started", "adopted", "error"];
|
|
33
|
+
const knownReaperFields = (reaper) =>
|
|
34
|
+
reaper &&
|
|
35
|
+
Object.fromEntries(
|
|
36
|
+
REAPER_FIELDS.filter((field) => reaper[field] !== undefined).map(
|
|
37
|
+
(field) => [field, reaper[field]]
|
|
38
|
+
)
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
// Mutated, never copied: the guard writes this same object for the life of the node - a death, a restart,
|
|
42
|
+
// a give-up - and a copy taken here freezes the status endpoint on what was true at boot.
|
|
43
|
+
const identify = (state, agent) =>
|
|
44
|
+
Object.assign(state, {
|
|
45
|
+
name: agent.name,
|
|
46
|
+
title: agent.title,
|
|
47
|
+
kind: agent.kind,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// Symmetric with the guard path's own notes: an operator reading the boot log sees this line, and a
|
|
51
|
+
// caller reading status.supervisionReport sees the same words, not just the absence of `kind: "guard"`.
|
|
52
|
+
const GUARD_UNUSED_NOTE =
|
|
53
|
+
"the bundled process guard is present but unused: this Harper supervises the agents natively, so the guard never runs.";
|
|
54
|
+
|
|
55
|
+
// Harper's own sidecar, one call per process; it writes the config files behind its own sweep. Also the seam
|
|
56
|
+
// test/support/component.js fakes, so most of the component's supervision tests run through this branch.
|
|
57
|
+
const harperSupervisor = (scope, log) => ({
|
|
58
|
+
kind: "harper",
|
|
59
|
+
async start(agents, { configFiles, fingerprintParts }) {
|
|
60
|
+
log.warn(`Datadog supervisor: ${GUARD_UNUSED_NOTE}`);
|
|
61
|
+
const processes = await Promise.all(
|
|
62
|
+
agents.map((agent) =>
|
|
63
|
+
scope.processes
|
|
64
|
+
.start({
|
|
65
|
+
name: agent.name,
|
|
66
|
+
title: agent.title,
|
|
67
|
+
command: agent.command,
|
|
68
|
+
args: agent.args,
|
|
69
|
+
// On BOTH: start() writes after its own sweep, so naming them on one alone lets the
|
|
70
|
+
// other spawn before the files exist.
|
|
71
|
+
configFiles,
|
|
72
|
+
fingerprint: fingerprintParts,
|
|
73
|
+
exitHint: agent.exitHint,
|
|
74
|
+
verify: agent.verify,
|
|
75
|
+
})
|
|
76
|
+
.then((state) => {
|
|
77
|
+
// Only here: the guard reports its own verdicts through the log it was handed.
|
|
78
|
+
if (state.verified !== true) {
|
|
79
|
+
log.error(
|
|
80
|
+
`Datadog supervisor: the ${agent.title} started but did not verify: ${state.verifyDetail ?? "no detail"}`
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
return identify(state, agent);
|
|
84
|
+
})
|
|
85
|
+
.catch((error) =>
|
|
86
|
+
unstarted(agent, describeSpawnFailure(error, agent.command))
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
);
|
|
90
|
+
return {
|
|
91
|
+
processes,
|
|
92
|
+
reaper: knownReaperFields(scope.processes.reaper),
|
|
93
|
+
report: [GUARD_UNUSED_NOTE],
|
|
94
|
+
};
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// The reaper takes its own lock beside the agents', so its name is what a second component sharing the
|
|
99
|
+
// directory would collide on; this one names the package rather than taking the guard's generic default.
|
|
100
|
+
const REAPER_NAME = "datadog-agent-reaper";
|
|
101
|
+
|
|
102
|
+
/** The bundled guard, one call for both agents. `spawn` is the entry module's own, which is the one Harper constrains. */
|
|
103
|
+
const guardSupervisor = (log, spawn) => ({
|
|
104
|
+
kind: "guard",
|
|
105
|
+
async start(agents, { runtime, configFiles, fingerprintParts }) {
|
|
106
|
+
// Harper's start() writes these itself; on this path nothing else will, and both agents read them.
|
|
107
|
+
writeConfigFiles(configFiles, log);
|
|
108
|
+
let result;
|
|
109
|
+
try {
|
|
110
|
+
result = await guard({
|
|
111
|
+
pidDir: runtime.paths.pidDir,
|
|
112
|
+
spawn,
|
|
113
|
+
log,
|
|
114
|
+
version: fingerprint(...fingerprintParts),
|
|
115
|
+
// What makes the fingerprint a replacement rather than a second lock holder: without it a rotated
|
|
116
|
+
// key leaves the old agent running under no lock, so not even the reaper below can stop it again.
|
|
117
|
+
stopOrphans: true,
|
|
118
|
+
processes: agents.map((agent) => ({
|
|
119
|
+
name: agent.name,
|
|
120
|
+
title: agent.title,
|
|
121
|
+
binaryPath: agent.command,
|
|
122
|
+
args: agent.args,
|
|
123
|
+
exitHint: agent.exitHint,
|
|
124
|
+
verify: agent.verify,
|
|
125
|
+
})),
|
|
126
|
+
reaper: {
|
|
127
|
+
name: REAPER_NAME,
|
|
128
|
+
logFile: runtime.paths.reaperLog,
|
|
129
|
+
// Harper records its own pid here, so a restart inside the grace window keeps the agents
|
|
130
|
+
// running for the replacement node to adopt.
|
|
131
|
+
...(runtime.root
|
|
132
|
+
? { replacementPidFile: join(runtime.root, "hdb.pid") }
|
|
133
|
+
: {}),
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
} catch (error) {
|
|
137
|
+
// Anything guard() does not catch itself reaches here, and no enumeration of those stays true: the
|
|
138
|
+
// last one written missed ctx.log.info. It starts the agents in order and rejects out of the one
|
|
139
|
+
// it was on, so an agent ahead of it is running under a committed lock with nothing watching it.
|
|
140
|
+
const message =
|
|
141
|
+
`${error instanceof Error ? error.message : String(error)}. Neither agent is reported ` +
|
|
142
|
+
`started because the call threw before it reported either; an agent it had already spawned ` +
|
|
143
|
+
`is still running unsupervised, under a lock in ${runtime.paths.pidDir}`;
|
|
144
|
+
log.error(
|
|
145
|
+
`Datadog supervisor: the guard call for both agents threw: ${error.stack ?? message}`
|
|
146
|
+
);
|
|
147
|
+
return {
|
|
148
|
+
// Not describeSpawnFailure: its ENOEXEC/EACCES/ENOENT translations are harperSupervisor's
|
|
149
|
+
// per-agent contract, where the error IS that one agent's own spawn rejection. Here the cause
|
|
150
|
+
// is unproven to be about either binary, so both agents get the same raw message.
|
|
151
|
+
processes: agents.map((agent) => unstarted(agent, message)),
|
|
152
|
+
report: [message],
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
return {
|
|
156
|
+
processes: result.processes.map((state, index) =>
|
|
157
|
+
identify(state, agents[index])
|
|
158
|
+
),
|
|
159
|
+
// Whole: the guard's index.js built this and its ReaperState typedef is the shape. Filtering it here
|
|
160
|
+
// dropped the reaper's own pid, which is the one field an operator needs to find the process.
|
|
161
|
+
reaper: result.reaper,
|
|
162
|
+
report: result.report,
|
|
163
|
+
};
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// The one place either supervisor is chosen. Everything downstream takes what this returns and never reads
|
|
168
|
+
// `scope.processes` again, so a second reading cannot disagree with the first.
|
|
169
|
+
export const supervisorFor = (scope, { log, spawn }) =>
|
|
170
|
+
supervisesNatively(scope)
|
|
171
|
+
? harperSupervisor(scope, log)
|
|
172
|
+
: guardSupervisor(log, spawn);
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// What separates "the process is up" from "the process is the agent this node needs". A live process of the
|
|
2
|
+
// wrong kind, or a stray socket on the port, passes every cheaper check and is reported healthy.
|
|
3
|
+
|
|
4
|
+
import { describeExit } from "./agent-exit.js";
|
|
5
|
+
import { debugVarsUrl } from "./delivery.js";
|
|
6
|
+
import { parseJson, pollEndpoint } from "./probe.js";
|
|
7
|
+
|
|
8
|
+
/** The path dd-trace posts spans to. A receiver that does not advertise it is not one this node can use. */
|
|
9
|
+
const TRACE_ENDPOINT = "/v0.4/traces";
|
|
10
|
+
|
|
11
|
+
// Stated once so resources.js's probe blocklist can never name a different receiver URL than the one this
|
|
12
|
+
// file verifies against; a mismatch there is exactly the traffic the blocklist exists to keep out of APM.
|
|
13
|
+
export const receiverInfoUrl = (port) => `http://127.0.0.1:${port}/info`;
|
|
14
|
+
|
|
15
|
+
/** The core agent's expvar endpoint, built the same way for the same reason. */
|
|
16
|
+
export const expvarUrl = (port) => `http://127.0.0.1:${port}/debug/vars`;
|
|
17
|
+
|
|
18
|
+
// Both verifiers poll the same way and differ only in url; state.exited is the one giveUp condition either
|
|
19
|
+
// agent has, since a dead process cannot bind the port it is being polled for.
|
|
20
|
+
const pollAgent = (url, state) =>
|
|
21
|
+
pollEndpoint({ url, giveUp: () => state.exited === true });
|
|
22
|
+
|
|
23
|
+
/** The pid this node's supervisor started, or null. A guard attempt that never reached a spawn leaves it undefined (the guard's src/supervise.js:156), and comparing against that reads a healthy agent as stale. */
|
|
24
|
+
const heldPid = (state) => (typeof state?.pid === "number" ? state.pid : null);
|
|
25
|
+
|
|
26
|
+
/** The pid an expvar body reports, or null when it publishes none. The trace-agent publishes it as a string, so a strict number test reads a real pid as no pid at all. */
|
|
27
|
+
function expvarPid(vars) {
|
|
28
|
+
const pid = Number(vars?.pid);
|
|
29
|
+
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Both agents get the same verdict for the same reason, so the operator reads one sentence either way. */
|
|
33
|
+
const foreignPid = (answering, held, url, title) =>
|
|
34
|
+
`a ${title} answered ${url} as pid ${answering}, not the pid ${held} this node started, so the port ` +
|
|
35
|
+
`belongs to a process nothing here supervises; stop it, or remove the stale .pid file under the node's ` +
|
|
36
|
+
`pids/ directory, and restart`;
|
|
37
|
+
|
|
38
|
+
/** What the process did, when it did anything. A signalled exit reports no code, so `code || 0` reads it as a clean stop. */
|
|
39
|
+
function exitDetail(state) {
|
|
40
|
+
if (state?.exited !== true) return "";
|
|
41
|
+
if (typeof state.signal !== "string" && typeof state.code !== "number") {
|
|
42
|
+
return " The process this node started is gone.";
|
|
43
|
+
}
|
|
44
|
+
const { detail } = describeExit(state.code ?? null, state.signal ?? null);
|
|
45
|
+
return ` The process this node started ${detail}.`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Prove the trace-agent serves the endpoint dd-trace posts to and is the process this node started; a bare TCP connect is satisfied by any stray socket, and dd-trace reports a successful flush either way. */
|
|
49
|
+
async function verifyTraceAgent(state, { paths, ports }) {
|
|
50
|
+
if (ports.receiver === 0) {
|
|
51
|
+
return {
|
|
52
|
+
ok: false,
|
|
53
|
+
detail:
|
|
54
|
+
"apm_config.receiver_port is 0 (DD_APM_RECEIVER_PORT): the HTTP receiver is off, and " +
|
|
55
|
+
"dd-trace drops every span unless it is pointed at a Unix socket instead",
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
if (ports.debug === 0) {
|
|
59
|
+
return {
|
|
60
|
+
ok: false,
|
|
61
|
+
detail:
|
|
62
|
+
"apm_config.debug.port is 0 (DD_APM_DEBUG_PORT), so the trace-agent publishes no expvar and " +
|
|
63
|
+
"nothing can tie whatever holds the receiver port to the process this node started",
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
const url = receiverInfoUrl(ports.receiver);
|
|
67
|
+
const body = await pollAgent(url, state);
|
|
68
|
+
const endpoints = parseJson(body)?.endpoints;
|
|
69
|
+
const serving =
|
|
70
|
+
Array.isArray(endpoints) &&
|
|
71
|
+
endpoints.some(
|
|
72
|
+
(entry) => typeof entry === "string" && entry.includes(TRACE_ENDPOINT)
|
|
73
|
+
);
|
|
74
|
+
if (!serving) {
|
|
75
|
+
return {
|
|
76
|
+
ok: false,
|
|
77
|
+
detail:
|
|
78
|
+
body === null
|
|
79
|
+
? `nothing answered ${url}, so dd-trace has nowhere to send spans.${exitDetail(state)} ` +
|
|
80
|
+
`Check apm_config.enabled in ${paths.configFile} and DD_APM_ENABLED, then read ${paths.traceLog}`
|
|
81
|
+
: `whatever answered ${url} does not advertise ${TRACE_ENDPOINT}, so it is not a trace-agent this node can rely on`,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
// /info identifies nobody: an agent left from an earlier boot answers it exactly like this node's own,
|
|
85
|
+
// and it is the one holding the port this node's agent could not bind. The expvar names the pid.
|
|
86
|
+
const identity = debugVarsUrl(ports.debug);
|
|
87
|
+
const vars = parseJson(await pollAgent(identity, state));
|
|
88
|
+
const answering = expvarPid(vars);
|
|
89
|
+
if (answering === null) {
|
|
90
|
+
return {
|
|
91
|
+
ok: false,
|
|
92
|
+
detail:
|
|
93
|
+
`something serves ${TRACE_ENDPOINT} on 127.0.0.1:${ports.receiver}, but nothing answering ` +
|
|
94
|
+
`${identity} named a pid, so it cannot be shown to be the trace-agent this node ` +
|
|
95
|
+
`started.${exitDetail(state)} Read ${paths.traceLog}`,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
const held = heldPid(state);
|
|
99
|
+
if (held !== null && answering !== held) {
|
|
100
|
+
return {
|
|
101
|
+
ok: false,
|
|
102
|
+
detail: foreignPid(answering, held, identity, "trace-agent"),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
ok: true,
|
|
107
|
+
detail: `the APM receiver serves ${TRACE_ENDPOINT} on 127.0.0.1:${ports.receiver} as pid ${answering}; dd-trace has somewhere to send spans`,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Prove the process behind the lock is a core agent: only it publishes aggregator and forwarder, and a live process of the wrong kind passes every cheaper check. */
|
|
112
|
+
async function verifyCoreAgent(state, { paths, ports }) {
|
|
113
|
+
if (ports.expvar === 0) {
|
|
114
|
+
return {
|
|
115
|
+
ok: false,
|
|
116
|
+
detail:
|
|
117
|
+
"expvar_port is 0 (DD_EXPVAR_PORT), so nothing can confirm the core agent is the process " +
|
|
118
|
+
"holding its PID lock, and no host metric can be shown to be collected",
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
const url = expvarUrl(ports.expvar);
|
|
122
|
+
const vars = parseJson(await pollAgent(url, state));
|
|
123
|
+
if (!vars || !("aggregator" in vars) || !("forwarder" in vars)) {
|
|
124
|
+
return {
|
|
125
|
+
ok: false,
|
|
126
|
+
detail:
|
|
127
|
+
`nothing answering ${url} identified itself as a core agent, so host metrics and tags are ` +
|
|
128
|
+
`going nowhere while traces may still flow.${exitDetail(state)} A stale PID lock adopted by ` +
|
|
129
|
+
`the wrong process produces exactly this; read ${paths.coreLog} and check ${paths.configFile}`,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
// Measured on 7.82.1: the core agent's expvar publishes no pid at all, so this engages only against a
|
|
133
|
+
// build that grows one. What it cannot do is refuse a healthy agent for not publishing it.
|
|
134
|
+
const held = heldPid(state);
|
|
135
|
+
const answering = expvarPid(vars);
|
|
136
|
+
if (answering !== null && held !== null && answering !== held) {
|
|
137
|
+
return {
|
|
138
|
+
ok: false,
|
|
139
|
+
detail: foreignPid(answering, held, url, "core agent"),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
ok: true,
|
|
144
|
+
detail:
|
|
145
|
+
`the core agent serves expvar on 127.0.0.1:${ports.expvar}` +
|
|
146
|
+
(held === null ? "" : ` as pid ${held}`),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// A supervisor that never started it has nothing to verify: both verifiers would poll on, and read whatever
|
|
151
|
+
// else holds the port. Strictly false, because a caller that reports no `started` field does have a process.
|
|
152
|
+
const notStarted = (state) =>
|
|
153
|
+
state?.started === false
|
|
154
|
+
? {
|
|
155
|
+
ok: false,
|
|
156
|
+
detail:
|
|
157
|
+
`this node never started it${state.error ? `: ${state.error}` : ""}, so nothing was ` +
|
|
158
|
+
`polled and anything answering its port belongs to another process`,
|
|
159
|
+
}
|
|
160
|
+
: null;
|
|
161
|
+
|
|
162
|
+
/** The verdict for one launched agent. Both supervisors call this, so neither can reach a verdict the other cannot. */
|
|
163
|
+
export const verifyLaunch = (agent, state, context) => {
|
|
164
|
+
// Each supervisor verifies once, after the first spawn, and then rewrites `pid` and `restarts` on this
|
|
165
|
+
// same object without retaking the verdict. The pid it was taken against is the only record of that.
|
|
166
|
+
state.verifiedPid = state.pid ?? null;
|
|
167
|
+
return (
|
|
168
|
+
notStarted(state) ??
|
|
169
|
+
(agent.kind === "trace"
|
|
170
|
+
? verifyTraceAgent(state, context)
|
|
171
|
+
: verifyCoreAgent(state, context))
|
|
172
|
+
);
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
/** True once the process the verdict describes has been replaced. A verdict taken against no pid at all cannot go stale, because it never named one. */
|
|
176
|
+
const stale = (state) =>
|
|
177
|
+
typeof state?.verifiedPid === "number" && state.verifiedPid !== state.pid;
|
|
178
|
+
|
|
179
|
+
/** The verdict as it stands now. Read at the endpoint rather than stamped at boot, because the supervisor keeps writing pid and restarts to the same object for the life of the node. */
|
|
180
|
+
export const currentVerdict = (state) =>
|
|
181
|
+
stale(state)
|
|
182
|
+
? {
|
|
183
|
+
...state,
|
|
184
|
+
verified: null,
|
|
185
|
+
verifyDetail:
|
|
186
|
+
`the last verdict was taken against pid ${state.verifiedPid}, which this node has since ` +
|
|
187
|
+
`restarted ${state.restarts} time(s) as pid ${state.pid}. Nothing has verified the process ` +
|
|
188
|
+
`now running; what the dead one proved was: ${state.verifyDetail}`,
|
|
189
|
+
}
|
|
190
|
+
: state;
|