Go

Go SDK

Build, package, and ship ServiceRadar WebAssembly checker plugins in Go (TinyGo) with the official serviceradar-sdk-go module.

serviceradar-sdk-go is the supported way to write ServiceRadar checker plugins in Go and compile them to a sandboxed WebAssembly artifact with TinyGo. The SDK owns the low-level host ABI so your plugin code stays focused on the check itself.

Repository: https://code.carverauto.dev/carverauto/serviceradar-sdk-go

The SDK handles:

  • Config decoding from the host (get_config).
  • A result builder for the serviceradar.plugin_result.v1 payload.
  • A logging bridge to the host.
  • Host-proxied HTTP, TCP, UDP, and WebSocket I/O.
  • Device discovery envelopes for inventory-producing plugins.
  • OCSF event emission, alert-promotion hints, and first-class telemetry.
  • Signal schema and display contract references for package-managed logs and events.

Install

go get code.carverauto.dev/carverauto/serviceradar-sdk-go

The module targets go 1.25 and is compiled with TinyGo for the wasi target.

Your first plugin

A plugin exports a single run_check function. sdk.Execute wraps your check, submits the result to the host, and converts any returned error into a critical result automatically.

//go:build tinygo

package main

import (
    "fmt"

    "code.carverauto.dev/carverauto/serviceradar-sdk-go/sdk"
)

type Config struct {
    URL    string  `json:"url"`
    WarnMS float64 `json:"warn_ms"`
    CritMS float64 `json:"crit_ms"`
}

//export run_check
func run_check() {
    _ = sdk.Execute(func() (*sdk.Result, error) {
        cfg := Config{URL: "https://example.com/health"}
        if err := sdk.LoadConfig(&cfg); err != nil {
            return nil, err
        }

        resp, err := sdk.HTTP.Get(cfg.URL)
        if err != nil {
            return nil, fmt.Errorf("http request failed: %w", err)
        }

        latency := float64(resp.Duration.Milliseconds())
        thresholds := sdk.Thresholds(cfg.WarnMS, cfg.CritMS)

        return sdk.NewResult().
            WithSummary(fmt.Sprintf("http %d in %.0fms", resp.Status, latency)).
            WithThresholds(latency, thresholds.Warn, thresholds.Crit).
            WithMetric("latency_ms", latency, "ms", thresholds).
            WithStatCard("Latency", fmt.Sprintf("%.0fms", latency), "success"), nil
    })
}

func main() {}

The main function stays empty: the host calls the exported run_check symbol directly.

Bundled examples

The repository ships runnable examples under examples/:

  • http-check — HTTP latency check with thresholds and events.
  • tcp-check — TCP connectivity check with optional write/read.
  • udp-check — UDP send check with a bytes-sent metric.
  • widgets-check — stat card, table, sparkline, and markdown widgets.
  • sample-northbound — northbound result example.

Build

# Requires TinyGo
cd examples/http-check
tinygo build -o plugin.wasm -target=wasi ./

The result is a single plugin.wasm you package alongside a manifest and config schema. See Plugin packages for the package layout an operator installs.

Result builder

Result is the serviceradar.plugin_result.v1 payload. Every field has both a conventional setter (SetSummary, AddMetric, …) and a fluent builder (WithSummary, WithMetric, …) so you can pick a style.

Constructors and status

sdk.NewResult()          // status defaults to UNKNOWN
sdk.Ok("all good")       // StatusOK
sdk.Warning("degraded")  // StatusWarning
sdk.Critical("down")     // StatusCritical
sdk.Unknown("no data")   // StatusUnknown

Status values are sdk.StatusOK, sdk.StatusWarning, sdk.StatusCritical, and sdk.StatusUnknown (serialized as OK, WARNING, CRITICAL, UNKNOWN).

Defaults and zero values

Defaults are applied at the edge — right before serialization — so Serialize never mutates your object. A bare var r sdk.Result still serializes to a valid payload:

  • SchemaVersion defaults to 1.
  • Status defaults to UNKNOWN.
  • Summary defaults to the status string.
  • ObservedAt defaults to “now” in RFC3339Nano.

Builders

res := sdk.NewResult().
    WithSummary("all good").
    WithDetails("extended diagnostics").
    WithMetric("cpu", 10, "%", nil).
    WithLabel("version", "1.2.3")
Builder Purpose
WithStatus / WithSummary / WithDetails Core status fields
WithMetric(name, value, unit, thresholds) Append a structured metric
WithLabel(key, value) Attach a label
WithThresholds(value, warn, crit) Derive status from a value
WithStatCard(label, value, tone) stat_card display widget
WithTable(data, layout) table display widget
WithSparkline(label, points, tone) sparkline display widget
WithMarkdown(markdown) markdown display widget
WithEvent / WithOCSFEvent Attach OCSF events
WithImmediateAlert(conditionID) Request immediate alert promotion
ForTarget(ctx) Scope the result to a descriptor target

Thresholds

Thresholds(warn, crit) builds a *ThresholdSpec without helper boilerplate; each bound is set only when greater than zero.

thresholds := sdk.Thresholds(50, 100)
res.WithMetric("latency_ms", 10, "ms", thresholds)
res.WithThresholds(10, thresholds.Warn, thresholds.Crit)

Error handling

Execute accepts a func() (*sdk.Result, error) and returns an error. If your function returns a non-nil error, the SDK auto-generates a critical result (or upgrades your result to critical) and records the error details in the payload — so the happy path stays concise while failures still surface.

err := sdk.Execute(func() (*sdk.Result, error) {
    return sdk.Ok("ok"), nil
})
if err != nil {
    // Optional: submit/serialize errors (logging is already handled by the SDK).
}

Host I/O

Host I/O is proxied through the agent runtime, which enforces the domain and port allowlists declared in your plugin manifest. Context-aware variants exist to match Go expectations (they check ctx.Err() before the host call; TinyGo/WASM is synchronous today, but the API is stable for future cancellation support).

  • HTTP: sdk.HTTP.Get, sdk.HTTP.Post, sdk.HTTP.Do, plus GetContext / PostContext / DoContext.
  • TCP: sdk.TCPDialContext, (*TCPConn).ReadContext, (*TCPConn).WriteContext.
  • UDP: sdk.UDPSendToContext.
  • WebSocket: sdk.WebSocketDialContext, sdk.WebSocketConnectWithHeaders, (*WebSocketConn).SendContext / RecvContext.
conn, err := sdk.WebSocketDialContext(ctx, "ws://localhost:8080/ws", 10*time.Second)
if err != nil {
    return nil, fmt.Errorf("websocket dial failed: %w", err)
}
defer conn.Close()

if err := conn.SendContext(ctx, []byte(`{"method":"getInfo"}`), 10*time.Second); err != nil {
    return nil, err
}
buf := make([]byte, 4096)
n, err := conn.RecvContext(ctx, buf, 10*time.Second)

WebSocket connections require the manifest capabilities websocket_connect, websocket_send, websocket_recv, and websocket_close. Use WebSocketConnectWithHeaders to send headers (for example Authorization) on the initial handshake.

Events and alert hints

The result payload carries optional fields that drive event promotion. They are ignored safely by older control-plane builds.

  • events — a list of OCSF Event Log Activity objects.
  • alert_hint — a boolean requesting immediate promotion.
  • condition_id — a key used for de-duplication and auto-clear.
res := sdk.Critical("HTTP request failed")
res.EmitEvent(sdk.SeverityCritical, "HTTP request failed", "http_request_failed")
res.RequestImmediateAlert("http_request_failed")

Severities are sdk.SeverityInfo, sdk.SeverityWarning, sdk.SeverityError, and sdk.SeverityCritical. Build a standalone event with sdk.NewOCSFEventLogActivity(message, severity).

First-class telemetry

Use result-attached events for check-scoped annotations. For logs or events that should be ingested independently of the check result, declare the emit_telemetry capability and send a telemetry batch:

event := sdk.NewOCSFEventLogActivity("camera motion", sdk.SeverityWarning)
record := sdk.NewOCSFTelemetryRecord(event).WithSignalSchemaRef(sdk.SignalSchemaRef{
    ProducerID:             "axis-camera",
    ProducerVersion:        "0.1.0",
    SchemaID:               "com.carverauto.axis_camera.event_log",
    SchemaVersion:          "1.0.0",
    DisplayContractID:      "com.carverauto.axis_camera.event_log.display",
    DisplayContractVersion: "1.0.0",
    DisplayContract:        "display/event_log_activity.display.json",
    SignalType:             sdk.SignalSchemaSignalTypeEvent,
    PayloadKind:            sdk.SignalSchemaPayloadKindOCSFEvent,
})

err := sdk.EmitTelemetry(sdk.TelemetryBatch{
    Source:  sdk.TelemetrySource{SourceType: "axis-camera", SourceInstance: "front-door"},
    Records: []sdk.TelemetryRecord{record},
})

AttachSignalSchemaRef writes the ServiceRadar extension metadata under metadata.service_radar.signal_schema. See Signal display contracts for the package side of this contract.

Device discovery

Inventory-producing plugins can emit serviceradar.device_discovery.v1 envelopes inside the normal result payload (the DeviceDiscovery field). Core ingests these records and reconciles them into ocsf_devices.

Policy inputs

For policy-driven plugin assignments, decode and validate the typed serviceradar.plugin_inputs.v1 payload:

var payload sdk.PluginInputsPayload
if err := sdk.LoadConfig(&payload); err != nil {
    return nil, err
}
if err := payload.Validate(); err != nil {
    return nil, err
}

devices := payload.ItemsByEntity("devices")
err := payload.EachItem(func(item sdk.PluginInputItem) error {
    // item.Entity: "devices" | "interfaces" | ...
    // item.Item:   resolved fields (uid/ip/if_name/...)
    return nil
})

Helpers include sdk.ParsePluginInputsJSON, sdk.ParsePluginInputsMap, and the payload methods FlattenItems, ItemsByEntity, and ItemsByName.

Northbound actions

Northbound action plugins let ServiceRadar select a device, interface, or event and call an external tool from a WASM plugin — NMS lookups, configuration management actions, remediation previews, or ticket-driven external API calls.

Declare each exported action in plugin.yaml under actions:

actions:
  - action_id: sample.device.lookup
    version: 1.0.0
    label: Sample Device Lookup
    scopes: [device]
    required_context:
      - device.uid
      - device.ip
    safety_classification: read_only
    requires_confirmation: false
    result_schema_version: serviceradar.northbound_action_result.v1
    input_schema:
      type: object
      additionalProperties: false
      properties:
        query_mode:
          type: string
          enum: [summary, full]
          default: summary

At runtime ServiceRadar merges the plugin assignment config with an action_invocation envelope. Load it with sdk.LoadActionConfig(), inspect ActionInvocation.ActionID, read selected target snapshots from ActionInvocation.Targets, and return a structured ActionResult:

hostConfig, err := sdk.LoadActionConfig()
if err != nil {
    _ = sdk.SubmitActionResult(sdk.ActionFailed("config_error", err.Error()))
    return
}

invocation := hostConfig.ActionInvocation
result := sdk.ActionSucceeded("external lookup completed")

for _, target := range invocation.Targets {
    result.AddTargetResult(sdk.ActionTargetResult{
        DeviceUID: target.DeviceUID,
        Status:    sdk.ActionStatusSucceeded,
        Result: map[string]any{
            "device_ip": target.Address(),
            "api_query": "GET /devices/" + target.Address(),
        },
    })
}

_ = sdk.SubmitActionResult(result)

Device actions normally require device.uid and device.ip; interface actions should require the device address plus a stable interface identifier or name (for example device.ip and interface.name). Keep operator input in the descriptor input_schema — ServiceRadar supplies the submitted values in ActionInvocation.InputValues. Action output is operator-visible audit data, so include external correlation IDs, URLs, and operation names, but never credentials or secrets.

The examples/sample-northbound example simulates both a device-scoped lookup and an interface-scoped audit/remediation preview — use it as the reference shape for manifests, action result payloads, and tests.

Host ABI

The SDK imports host functions from the env module and exports alloc / dealloc so the host can write into plugin memory. The imported functions are:

get_config        log               submit_result      emit_telemetry
http_request      tcp_connect       tcp_read           tcp_write        tcp_close
udp_sendto        websocket_connect websocket_send     websocket_recv   websocket_close
camera_media_open camera_media_write camera_media_heartbeat camera_media_close

Each capability you call must be declared in the plugin manifest. Payloads are bounded: results are capped at sdk.MaxPayloadBytes (2 MiB) and HTTP responses at sdk.MaxHTTPResponseBytes (4 MiB).

CI and versioning

The repository’s .forgejo/workflows/ci.yml runs formatting, vet, lint, and tests on every change. Consumers pin a tagged module version with go get. The Rust SDK targets the same V2 plugin contract for teams that prefer Rust-native tooling.