Socket & runtime files

APM's CLI and daemon talk over a Unix domain socket in a root-owned directory. Only root and members of group apm can open it; the daemon reads the connecting process's uid from the socket and decides per command what that caller may do (see who may run what in the manual).

ItemPathNotes
Control socket/run/apm/apm.sockDirectory 0750 root:apm, socket 0660 root:apm, both set without following symlinks. A daemon started by a non-root user (development) uses $XDG_RUNTIME_DIR/apm/apm.sock. The pre-2.8 abstract name @apm is still answered for root only, so a 2.7 CLI can verify the daemon it just installed; it goes away in 3.0.
PID file/run/apm/apm.pidBeside the socket since 2.9 (was /root/.apm/apm.pid). A non-root daemon keeps ~/.apm/apm.pid. Removed on clean exit.
Config (user)~/.apm/config.confLoaded automatically on daemon start if it exists.
Config (system)/etc/apm/apm.confAlso loaded automatically. Created by the installer (install.sh). Drop worker configs into /etc/apm/apm.conf.d/ — symlinks there are followed because root made them; a symlink the deploy user puts in its place is not.
Log (user)~/.apm/apm.logWritten when APM is running as a regular user.
Log (service)/var/log/apm.logWritten when running as the systemd service set up by the installer; 0664 root:apm.
Runtime dir/run/apm/Created by the unit (RuntimeDirectory=apm) or by the daemon itself when started another way.

Socket access control

Membership of group apm is the whole gate. The installer creates the group and adds the user who ran it; add anyone else with usermod (a new login is needed for the group to take effect). Root may do everything. A group member gets the read-only commands, may apm run workers that run as themselves, and may manage workers whose user is themselves; everything that reads or writes files as the daemon or touches every worker at once needs sudo.

# Let alice use the CLI (read-only + her own workers):
$ sudo usermod -aG apm alice

# A worker's own processes reach the socket only if their user is in the group —
# and are then bound by the same rules.

Checking the socket

# The socket and its mode
$ ls -la /run/apm/

# Listening sockets (the legacy @apm name shows too, for root only)
$ ss -xl | grep apm

# Is the daemon alive, and which version
$ apm -v

Hardening the service (optional)

The installer's unit already sets what is safe for every app: RuntimeDirectory=apm, OOMScoreAdjust=-900, LimitNOFILE=65536, ProtectKernelTunables, ProtectKernelModules, ProtectKernelLogs, SystemCallArchitectures=native, RestartSec=2 and StartLimitIntervalSec=0 so a crash-looping daemon is never parked in failed. Everything a worker inherits that could change what an app can do is left out on purpose. If your apps allow it, add a drop-in and test on a staging box first:

# /etc/systemd/system/apm.service.d/hardening.conf
[Service]
# Workers get a private /tmp: breaks apps that share sockets or files there with nginx, PHP-FPM, cron.
PrivateTmp=yes
# Files the daemon and the workers create are group-readable only: breaks a web server reading uploads.
UMask=0027
# Capabilities the daemon keeps; a worker running as root loses the rest (raw sockets, mounts, ...).
CapabilityBoundingSet=CAP_SETUID CAP_SETGID CAP_CHOWN CAP_DAC_OVERRIDE CAP_DAC_READ_SEARCH CAP_FOWNER CAP_KILL CAP_NET_BIND_SERVICE CAP_SYS_RESOURCE CAP_SETPCAP CAP_AUDIT_WRITE
# No raw or packet sockets anywhere in the tree.
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK

$ sudo systemctl daemon-reload && sudo systemctl restart apm

Nginx integration

APM runs its own proxy layer, so Nginx sits in front as an SSL terminator and vhost router, forwarding to APM's bound ports. Always set trust_proxy true in the worker config so APM's Vanguard firewall sees real client IPs.

Set trust_proxy. Without it, Vanguard rate-limits and bans Nginx's loopback address, not your actual visitors. Every worker behind Nginx should have trust_proxy true;.

HTTP reverse proxy

APM worker on port 3000, Nginx terminates SSL and forwards HTTP traffic.

nginx.conf
server {
    listen       443 ssl;
    server_name  myapp.example.com;

    ssl_certificate      /etc/letsencrypt/live/myapp.example.com/fullchain.pem;
    ssl_certificate_key  /etc/letsencrypt/live/myapp.example.com/privkey.pem;

    location / {
        proxy_pass          http://127.0.0.1:3000;
        proxy_http_version  1.1;
        proxy_set_header    Host               $host;
        proxy_set_header    X-Real-IP          $remote_addr;
        proxy_set_header    X-Forwarded-For    $proxy_add_x_forwarded_for;
        proxy_set_header    X-Forwarded-Proto  $scheme;
    }
}

# apm.conf — worker side
worker {
    name         myapp;
    exec         node;
    params       server.js;
    server       http://127.0.0.1:3000;
    trust_proxy  true;
}

WebSocket proxy

WebSocket upgrades require the Upgrade and Connection headers to be forwarded, plus a long read timeout so idle connections aren't killed.

nginx.conf
server {
    listen       443 ssl;
    server_name  myapp.example.com;

    ssl_certificate      /etc/letsencrypt/live/myapp.example.com/fullchain.pem;
    ssl_certificate_key  /etc/letsencrypt/live/myapp.example.com/privkey.pem;

    location /ws {
        proxy_pass          http://127.0.0.1:3001;
        proxy_http_version  1.1;
        proxy_set_header    Upgrade            $http_upgrade;
        proxy_set_header    Connection         "upgrade";
        proxy_set_header    Host               $host;
        proxy_set_header    X-Real-IP          $remote_addr;
        proxy_set_header    X-Forwarded-For    $proxy_add_x_forwarded_for;
        proxy_read_timeout  3600s;     # keep idle WS connections alive
        proxy_send_timeout  3600s;
    }
}

# apm.conf — worker side
worker {
    name         myapp;
    exec         node;
    params       server.js;
    server       ws://127.0.0.1:3001;
    trust_proxy  true;
}

HTTP + WebSocket on one domain

Route by path prefix — REST API on /, WebSocket on /ws. APM listens on two separate ports; Nginx splits traffic.

nginx.conf
server {
    listen       443 ssl;
    server_name  myapp.example.com;

    ssl_certificate      /etc/letsencrypt/live/myapp.example.com/fullchain.pem;
    ssl_certificate_key  /etc/letsencrypt/live/myapp.example.com/privkey.pem;

    # WebSocket endpoint — must come before location /
    location /ws {
        proxy_pass          http://127.0.0.1:3001;
        proxy_http_version  1.1;
        proxy_set_header    Upgrade            $http_upgrade;
        proxy_set_header    Connection         "upgrade";
        proxy_set_header    Host               $host;
        proxy_set_header    X-Real-IP          $remote_addr;
        proxy_set_header    X-Forwarded-For    $proxy_add_x_forwarded_for;
        proxy_read_timeout  3600s;
        proxy_send_timeout  3600s;
    }

    # HTTP API / everything else
    location / {
        proxy_pass          http://127.0.0.1:3000;
        proxy_http_version  1.1;
        proxy_set_header    Host               $host;
        proxy_set_header    X-Real-IP          $remote_addr;
        proxy_set_header    X-Forwarded-For    $proxy_add_x_forwarded_for;
        proxy_set_header    X-Forwarded-Proto  $scheme;
    }
}

# apm.conf — worker side
worker {
    name         myapp;
    exec         node;
    params       server.js;
    server       http://127.0.0.1:3000, ws://127.0.0.1:3001;
    trust_proxy  true;
}

Unix socket upstream

Instead of a TCP loopback port you can have APM bind its HTTP server to a Unix domain socket file — no port needed, no TCP handshake, marginally lower latency on the same host. Nginx connects with proxy_pass http://unix:… and an upstream block.

Socket path. In the APM config you can use a relative path like ./myapp.sock — APM resolves it against the worker's path directory. For example, if path is /opt/myapp, the socket will be at /opt/myapp/myapp.sock. Use that absolute path in your Nginx config. If you prefer a dedicated directory (e.g. /run/apm/), create it first: sudo mkdir -p /run/apm.
nginx.conf
# Define the upstream once, reuse across location blocks
# Use the absolute path that matches the worker's path + socket filename
upstream apm_myapp {
    server    unix:/opt/myapp/myapp.sock;
    keepalive 32;
}

server {
    listen       443 ssl;
    server_name  myapp.example.com;

    ssl_certificate      /etc/letsencrypt/live/myapp.example.com/fullchain.pem;
    ssl_certificate_key  /etc/letsencrypt/live/myapp.example.com/privkey.pem;

    location / {
        proxy_pass          http://apm_myapp;
        proxy_http_version  1.1;
        proxy_set_header    Connection         "";  # enable upstream keep-alive
        proxy_set_header    Host               $host;
        proxy_set_header    X-Real-IP          $remote_addr;
        proxy_set_header    X-Forwarded-For    $proxy_add_x_forwarded_for;
        proxy_set_header    X-Forwarded-Proto  $scheme;
    }
}

# apm.conf — relative path resolves to worker's path directory
worker {
    name         myapp;
    exec         node;
    params       server.js;
    path         /opt/myapp;
    server       http://unix:./myapp.sock;  # → /opt/myapp/myapp.sock
    trust_proxy  true;
}
Socket file permissions. APM creates the socket file owned by the worker's user. Nginx's worker process (often www-data) needs read+write access. Either run both under the same user or add Nginx's user to the APM worker's group.

HTTP redirect to HTTPS

nginx.conf
server {
    listen       80;
    server_name  myapp.example.com;
    return       301 https://$host$request_uri;
}

Apache integration

Apache uses mod_proxy, mod_proxy_http, and mod_proxy_wstunnel for reverse proxying. Enable them once, then use VirtualHost blocks per app.

enable modules
$ sudo a2enmod proxy proxy_http proxy_wstunnel rewrite headers ssl
$ sudo systemctl reload apache2
Set trust_proxy. Same rule as Nginx — every APM worker behind Apache needs trust_proxy true; so Vanguard sees real client IPs from X-Forwarded-For, not the loopback address.

HTTP reverse proxy

apache2/sites-available/myapp.conf
<VirtualHost *:443>
    ServerName  myapp.example.com

    SSLEngine             on
    SSLCertificateFile    /etc/letsencrypt/live/myapp.example.com/fullchain.pem
    SSLCertificateKeyFile /etc/letsencrypt/live/myapp.example.com/privkey.pem

    ProxyPreserveHost     On
    ProxyPass             /  http://127.0.0.1:3000/
    ProxyPassReverse      /  http://127.0.0.1:3000/

    RequestHeader  set  X-Forwarded-Proto  "https"
    RequestHeader  set  X-Real-IP          "%{REMOTE_ADDR}e"
</VirtualHost>

# apm.conf — worker side
worker {
    name         myapp;
    exec         node;
    params       server.js;
    server       http://127.0.0.1:3000;
    trust_proxy  true;
}

WebSocket proxy

mod_proxy_wstunnel handles the Upgrade handshake. The RewriteRule pattern matches the WebSocket path and rewrites the scheme to ws://.

apache2/sites-available/myapp.conf
<VirtualHost *:443>
    ServerName  myapp.example.com

    SSLEngine             on
    SSLCertificateFile    /etc/letsencrypt/live/myapp.example.com/fullchain.pem
    SSLCertificateKeyFile /etc/letsencrypt/live/myapp.example.com/privkey.pem

    RewriteEngine  On
    # Upgrade WebSocket connections on /ws
    RewriteCond  %{HTTP:Upgrade}  websocket  [NC]
    RewriteRule  ^/ws(/.*)?$      ws://127.0.0.1:3001/ws$1  [P,L]

    ProxyPreserveHost  On
    ProxyPass          /ws  ws://127.0.0.1:3001/ws
    ProxyPassReverse   /ws  ws://127.0.0.1:3001/ws

    RequestHeader  set  X-Forwarded-Proto  "https"
    RequestHeader  set  X-Real-IP          "%{REMOTE_ADDR}e"
</VirtualHost>

# apm.conf
worker {
    name         myapp;
    exec         node;
    params       server.js;
    server       ws://127.0.0.1:3001;
    trust_proxy  true;
}

HTTP + WebSocket on one domain

WS path matched first via RewriteRule, everything else falls through to the HTTP proxy.

apache2/sites-available/myapp.conf
<VirtualHost *:443>
    ServerName  myapp.example.com

    SSLEngine             on
    SSLCertificateFile    /etc/letsencrypt/live/myapp.example.com/fullchain.pem
    SSLCertificateKeyFile /etc/letsencrypt/live/myapp.example.com/privkey.pem

    RewriteEngine  On
    RewriteCond  %{HTTP:Upgrade}  websocket  [NC]
    RewriteRule  ^/ws(/.*)?$      ws://127.0.0.1:3001/ws$1  [P,L]

    ProxyPreserveHost  On
    ProxyPass          /ws  ws://127.0.0.1:3001/ws
    ProxyPassReverse   /ws  ws://127.0.0.1:3001/ws
    ProxyPass          /   http://127.0.0.1:3000/
    ProxyPassReverse   /   http://127.0.0.1:3000/

    RequestHeader  set  X-Forwarded-Proto  "https"
    RequestHeader  set  X-Real-IP          "%{REMOTE_ADDR}e"
</VirtualHost>

# apm.conf
worker {
    name         myapp;
    exec         node;
    params       server.js;
    server       http://127.0.0.1:3000, ws://127.0.0.1:3001;
    trust_proxy  true;
}

Unix socket upstream

Apache uses a pipe syntax to connect via a socket file: unix:/path/to/sock|http://localhost/. The http://localhost/ part sets the Host header sent upstream — it is not a TCP connection. Use the absolute path that matches your worker's path + socket filename.

apache2/sites-available/myapp.conf
<VirtualHost *:443>
    ServerName  myapp.example.com

    SSLEngine             on
    SSLCertificateFile    /etc/letsencrypt/live/myapp.example.com/fullchain.pem
    SSLCertificateKeyFile /etc/letsencrypt/live/myapp.example.com/privkey.pem

    ProxyPreserveHost  On
    ProxyPass          /  "unix:/opt/myapp/myapp.sock|http://localhost/"
    ProxyPassReverse   /  "unix:/opt/myapp/myapp.sock|http://localhost/"

    RequestHeader  set  X-Forwarded-Proto  "https"
    RequestHeader  set  X-Real-IP          "%{REMOTE_ADDR}e"
</VirtualHost>

# apm.conf — relative path resolves to worker's path directory
worker {
    name         myapp;
    exec         node;
    params       server.js;
    path         /opt/myapp;
    server       http://unix:./myapp.sock;  # → /opt/myapp/myapp.sock
    trust_proxy  true;
}

HTTP redirect to HTTPS

apache2/sites-available/myapp.conf
<VirtualHost *:80>
    ServerName     myapp.example.com
    RewriteEngine  On
    RewriteRule    ^  https://%{HTTP_HOST}%{REQUEST_URI}  [R=301,L]
</VirtualHost>

Enable the site

$ sudo a2ensite myapp
$ sudo apache2ctl configtest
$ sudo systemctl reload apache2

PHP / Python / Perl / Lua connectors

All four connectors implement the same IPC protocol as the Node.js module. Each is a single file — drop it into your project directory.

No compilation required. Each connector is a single self-contained source file with no C extensions. The only external dependency is a JSON library where the language doesn't ship one built-in.
download to current directory
# Node.js (CommonJS)
$ curl -fsSL https://processmanager.dev/connectors/apm_module.node.js -o apm_module.node.js

# Node.js (ESM)
$ curl -fsSL https://processmanager.dev/connectors/apm_module.node.mjs -o apm_module.node.mjs

# PHP
$ curl -fsSL https://processmanager.dev/connectors/apm_module.php -o apm_module.php

# Python
$ curl -fsSL https://processmanager.dev/connectors/apm_module.py -o apm_module.py

# Perl
$ curl -fsSL https://processmanager.dev/connectors/ApmModule.pm -o ApmModule.pm

# Lua
$ curl -fsSL https://processmanager.dev/connectors/apm_module.lua -o apm_module.lua

Each connector can update itself in-place after the first install — no curl required:

update in-place
# Node.js (CommonJS)
$ node apm_module.node.js -update

# Node.js (ESM)
$ node apm_module.node.mjs -update

# PHP
$ php apm_module.php -update

# Python
$ python3 apm_module.py -update

# Perl
$ perl ApmModule.pm -update

# Lua
$ lua apm_module.lua -update

PHP 7.4+

No external dependencies — json_encode / json_decode are always available in PHP.

server.php
require_once __DIR__ . '/apm_module.php';

$apm = new ApmModule(function(ApmSession $s) {
    // $s->protocol   — 'http' | 'ws' | 'tcp'
    // $s->method     — HTTP method string
    // $s->path       — request path
    // $s->headers    — associative array
    // $s->remoteIp   — real client IP
    // $s->cookies    — associative array
    // $s->instanceId — APM_INDEX env var

    $s->write('Hello World', ['_status' => 200, 'content-type' => 'text/plain']);
    $s->close();
});
$apm->run();

For WebSocket, assign $s->onData before returning from the connect callback:

$apm = new ApmModule(function(ApmSession $s) {
    $s->onData = function(string $data, bool $isBin) use ($s) {
        $s->write($data);  // echo back
    };
    $s->onClose = function() { /* cleanup */ };
});
$apm->run();

Python 3.6+

No external dependencies — json and struct are part of the standard library.

server.py
from apm_module import ApmModule, ApmSession

def on_connect(s: ApmSession):
    # s.protocol     — 'http' | 'ws' | 'tcp'
    # s.method       — HTTP method string
    # s.path         — request path
    # s.headers      — dict
    # s.remote_ip    — real client IP
    # s.cookies      — dict
    # s.instance_id  — APM_INDEX env var

    s.write(b'Hello World', {'_status': 200, 'content-type': 'text/plain'})
    s.close()

ApmModule(on_connect).run()

WebSocket echo example:

def on_connect(s: ApmSession):
    def on_data(data: bytes, is_binary: bool):
        s.write(data)
    s.on_data  = on_data
    s.on_close = lambda: None

ApmModule(on_connect).run()

Perl 5.10+

Requires the JSON module: cpan install JSON or apt install libjson-perl.

server.pl
use lib '.';
use ApmModule;

my $apm = ApmModule->new(sub {
    my ($s) = @_;
    # $s->{protocol}    — 'http' | 'ws' | 'tcp'
    # $s->{method}      — HTTP method
    # $s->{path}        — request path
    # $s->{headers}     — hashref
    # $s->{remote_ip}   — real client IP
    # $s->{cookies}     — hashref

    $s->write('Hello World', { '_status' => 200, 'content-type' => 'text/plain' });
    $s->close();
});
$apm->run();

WebSocket echo example:

my $apm = ApmModule->new(sub {
    my ($s) = @_;
    $s->{on_data} = sub {
        my ($data, $is_bin) = @_;
        $s->write($data);
    };
    $s->{on_close} = sub { };
});
$apm->run();

Lua 5.3+

Requires lua-cjson: luarocks install lua-cjson or apt install lua-cjson.

server.lua
local ApmModule = require('apm_module')

ApmModule.new(function(s)
    -- s.protocol     — 'http' | 'ws' | 'tcp'
    -- s.method       — HTTP method string
    -- s.path         — request path
    -- s.headers      — table
    -- s.remote_ip    — real client IP
    -- s.cookies      — table
    -- s.instance_id  — APM_INDEX env var

    s:write('Hello World', { ['_status'] = 200, ['content-type'] = 'text/plain' })
    s:close()
end):run()

WebSocket echo example:

ApmModule.new(function(s)
    s.on_data = function(data, is_binary)
        s:write(data)
    end
    s.on_close = function() end
end):run()

Common session API

All connectors expose the same session object fields and methods:

Field / methodTypeDescription
protocol stringhttp, ws, tcp or udp
method string|nullHTTP method (GET, POST, …); absent for tcp/udp
path stringRequest path (query string stripped)
path_arrayarrayPath split on /
query arrayRaw key=value parts of the query string
query_objectobject/dictParsed query params; a repeated key becomes an array
headers object/dictRequest headers as sent (lower-cased only when the worker sets lowercase_hdrs)
cookies object/dictParsed cookies
remote_ip stringReal client IP, resolved from proxy headers when trust_proxy is on
session_idstringUnique session identifier
session_dataobject/dictPersistent data, survives rolling restart
instance_idstringWorker index (APM_INDEX env)
on_data callableCalled with (data, is_binary) when data arrives
on_close callableCalled when the connection closes
write(data, headers?)methodSend response body or WebSocket frame; pass headers on first call. A bytes / Buffer argument is sent as a binary frame
write_binary(data, headers?)methodForce a binary frame (writeBinary in PHP; Node passes a Buffer to write). Also on streams. Connectors v3.1+
write_raw(data)methodSend raw bytes bypassing framing (TCP/WS; on HTTP it is framed like write)
close(code?, reason?)methodClose connection (HTTP status or WS close frame)
save_session_data()methodPersist session_data in the APM daemon
active() boolReturns false after connection has been closed
Download. All connector files are available on the Modules page with MD5 checksums.

IPC protocol

APM communicates with each managed worker process over stdin / stdout using a lightweight binary framing protocol. No Unix sockets, no network stack — just pipes. Every frame starts with the byte 0x05 followed by a big-endian uint32 length, a UTF-8 JSON header, the separator byte 0x03, and an optional binary payload.

The only difference between the two directions is how the uint32 length field is calculated — this is the most common source of bugs when writing a custom connector.
┌──────┬────────────────────┬──────────────────────┬──────┬─────────────────┐
│ 0x05 │  uint32 big-endian │      JSON header     │ 0x03 │  binary payload │
│  1 B │        4 B         │     json_len bytes   │  1 B │  binary_len B   │
└──────┴────────────────────┴──────────────────────┴──────┴─────────────────┘

uint32 = json_len + binary_len        # 0x03 separator NOT counted
frame_total = 1 + 4 + json_len + 1 + binary_len  # = uint32 + 6

Reading algorithm:

# wait for at least 5 bytes
if buf[0] != 0x05: resync()
payload_len = uint32_be(buf[1:5])
frame_len   = payload_len + 6        # wait for this many bytes total
sep         = frame.index(0x03, offset=5)
header      = json.parse(frame[5 : sep])
binary      = frame[sep+1 :]
┌──────┬────────────────────┬──────────────────────┬──────┬─────────────────┐
│ 0x05 │  uint32 big-endian │      JSON header     │ 0x03 │  binary payload │
│  1 B │        4 B         │     json_len bytes   │  1 B │  binary_len B   │
└──────┴────────────────────┴──────────────────────┴──────┴─────────────────┘

uint32 = json_len + 1 + binary_len    # 0x03 separator IS counted
frame_total = 1 + 4 + json_len + 1 + binary_len

Writing algorithm:

json_bytes = json.encode(header)
length     = len(json_bytes) + 1 + len(binary)  # note: +1 for 0x03
frame      = b'\x05' + uint32_be(length) + json_bytes + b'\x03' + binary
stdout.write(frame)
stdout.flush()

APM → worker: JSON header fields

The first frame for a session (_type: "connect") carries the full connection metadata; every later frame carries just _sessionId, _type and what that frame needs. Session-independent IPC frames have no _sessionId at all.

FieldTypeDescription
_sessionId string Unique identifier for this connection. Present on every frame.
_type string connect — a new session, with the metadata below. data — body / WebSocket frame / TCP bytes / UDP datagram arrived (for HTTP, always one data frame follows connect, empty for GET). event — lifecycle event. IPC frames use the types listed further down.
_event string When _type=event: connectionClosed — peer disconnected (also sent after every UDP datagram, which is its own session).
_sessionType string new for a fresh connection; moved for a session migrated to this instance by a rolling restart with session_persist.
_sessionData object Persisted data from the previous instance (present on a moved connect).
protocol string http, ws, tcp or udp.
method string HTTP method (GET, POST, …). Absent for tcp/udp.
path string Request path (without query string).
path_array array Path split on /, decoded.
query array Raw key=value parts of the query string.
query_object object Parsed query params. Multi-value keys become arrays.
headers object Request headers as sent; lower-cased only when the worker sets lowercase_hdrs. When trace_header is set, that header is injected with a unique id.
cookies object Parsed cookie map.
remoteAddressstring The resolved client IP: from CF-Connecting-IP / True-Client-IP / X-Real-IP / first X-Forwarded-For entry when trust_proxy is on, else the peer address. For udp, the sender's ip:port.
server_addressstring The listener the session arrived on, exactly as written in the worker's server field (e.g. http://unix:/run/app.sock). Not exposed by the shipped connectors; available to a custom one.
dataType string text or binary on data frames. For an HTTP body it is derived from Content-Type.

APM → worker: IPC frames

Inter-worker IPC shares the pipe. These frames carry no _sessionId; _rid identifies a request, _sid a stream, _peer a stream peer.

_typeFieldsMeaning
ipc_messagechannel, data; for a request also _rid and _needReply: trueA message on a channel this worker listens on. With _needReply, answer with an ipc_reply carrying the same _rid (first reply wins).
ipc_reply_rid, data (null on timeout / no listener)The reply to a request this worker sent.
stream_request_sid, channel, headerSomebody wants to open a stream on a channel this worker listens on. Answer with stream_accept or stream_reject.
stream_opened / stream_rejected_sidOutcome of a stream_open this worker sent (mediator side).
stream_attached_sid, _peerSent to the accepting worker: you are now a peer with this id.
stream_attach / stream_detach_sid, _peer, header (attach only)Sent to the mediator as peers come and go.
stream_data_sid, dataType + binary payload; _peer when it came from a peerData on an open stream.
stream_close_sidThe stream is gone (mediator closed it, last peer left, or a party died).

Worker → APM: _command values

Every session-bound frame must include _session (the session ID; _sessionId is accepted as an alias) and _command. IPC commands and metric / dash_value are session-independent.

CommandExtra fieldsDescription
write dataType (text|binary)
Any HTTP response header, e.g. content-type
_status — HTTP status code, as a JSON number
Send HTTP response body or WebSocket frame. Headers are only processed on the first write per session.
writeRaw — Send raw bytes directly to the socket, bypassing framing. TCP and WebSocket only — on an HTTP session the bytes are framed as a normal response body, same as write.
closeConnection code — HTTP status or WS close code (integer)
_reason — optional reason string
Close the connection. On HTTP, sends a bodiless response with the given status — only if no write preceded it, since a request consumes a single command. On WebSocket, sends a close frame.
saveSessionData _sessionData — object to persist Store arbitrary data in the daemon. On rolling restart the replacement worker receives it in _sessionData.
metric name — metric name string
value — number
type — counter (default) | gauge | timing
Emit a custom metric to the APM metrics pipeline (StatsD export). Not tied to a session — _session can be empty.
dash_value module_id — dashboard module id (number)
value — number, string or bool
color — optional CSS colour
Push a value to a GUI dashboard module (what apm.setDashValue() sends). Session-independent.
ipc_send channel, data Fire-and-forget broadcast to every child listening on the channel (the sender never gets an echo).
ipc_request channel, data, _rid — your request id
timeout — ms, optional
Send and wait; the first ipc_reply with your _rid wins, later ones are dropped, timeout delivers data: null.
ipc_reply _rid, data Answer an ipc_message that carried _needReply.
stream_open channel, header, _sid — your stream id
timeout — ms, optional
Become the mediator of a new stream; listeners on the channel receive stream_request.
stream_accept / stream_reject _sid, header (accept only) Answer a stream_request. Accepting makes this worker a peer.
stream_data _sid, dataType + binary payload Write to the stream: a mediator's write fans out to every peer, a peer's write reaches the mediator only.
stream_close _sid Leave (peer) or tear down (mediator) the stream.

Minimal custom connector skeleton

# pseudo-code — replace pack/unpack with your language's equivalent

def send_frame(header, binary=b''):
    j      = json.encode({**header, '_session': session_id})
    length = len(j) + 1 + len(binary)   # +1 for 0x03
    stdout.write(b'\x05' + pack('>I', length) + j + b'\x03' + binary)
    stdout.flush()

def read_frame():
    while len(buf) < 5: buf += stdin.read(4096)
    payload_len = unpack('>I', buf[1:5])[0]
    frame_len   = payload_len + 6
    while len(buf) < frame_len: buf += stdin.read(4096)
    frame, buf = buf[:frame_len], buf[frame_len:]
    sep    = frame.index(0x03, 5)
    header = json.decode(frame[5:sep])
    binary = frame[sep+1:]
    return header, binary

Troubleshooting

SyntaxError: Unexpected token '??=' (Node.js version mismatch)

Symptom

SyntaxError: Unexpected token '??='
    at wrapSafe (internal/modules/cjs/loader.js:...)
    at /opt/myapp/node_modules/@redis/client/dist/lib/client/index.js:727

The worker crashes immediately with a syntax error inside a node_modules package (@redis/client, @prisma/client, and similar modern packages commonly trigger this). The error is not a bug in APM or the package — it means the worker is running under Node.js 14 or older, which does not support the nullish coalescing assignment operator (??=) introduced in Node.js 15.

Why this happens under APM

When APM is started as a systemd service, it inherits the minimal systemd PATH (/usr/bin:/bin), which may resolve node to a system-installed Node.js 14 even when a newer version is available via nvm, n, or a manual install in /usr/local/bin. Running the same worker manually in your shell works because your shell's PATH picks up the newer version.

Fix 1 — set user or let APM infer it from the work directory

When APM runs as root and a worker has a user field (or a path whose owner is a non-root user), APM automatically wraps the worker in that user's login shell. This sources ~/.profile / ~/.bash_profile, loading nvm, PATH, and any other env vars the user has configured — exactly as if you su -'d to that user and ran the command yourself.

worker {
    name    myapp;
    exec    node;              # resolved from the user's PATH after profile loads
    params  /opt/myapp/server.js;
    path    /opt/myapp;        # if owner is non-root, user is inferred automatically
    user    deploy;            # optional: explicit user override
    restart true;
}

Fix 2 — specify the full Node.js path in the worker config

If you are not running APM as root, user-switching is unavailable. Specify the full path instead:

which node          # in a shell where the right version is active
node --version      # confirm it is 15+
worker {
    exec    /usr/local/bin/node;   # full path — not just "node"
    params  /opt/myapp/server.js;
    ...
}

Fix 3 — upgrade the system Node.js

If the old Node.js is the default system package, upgrade it via the NodeSource repository or use n / nvm to install a current LTS release system-wide.