> ## Documentation Index
> Fetch the complete documentation index at: https://docs.formae.io/llms.txt
> Use this file to discover all available pages before exploring further.

# 14 - Observability

> Instrument your plugin with the SDK's structured logger and metric registry, pulled from the request context, for debugging and monitoring through the formae agent.

The plugin SDK provides built-in observability support through structured logging and metrics. This allows you to instrument your plugins for debugging and monitoring without managing the infrastructure yourself.

## Logger

The SDK injects a logger into the context of every CRUD operation. The logger is pre-configured with plugin metadata (namespace, operation, resource type).

### Getting the logger

```go theme={"languages":{"custom":["/languages/pkl.json"]}}
func (p *Plugin) Create(ctx context.Context, req *resource.CreateRequest) (*resource.CreateResult, error) {
    log := plugin.LoggerFromContext(ctx)

    log.Info("creating file resource", "label", req.Label)
    // ... implementation ...
}
```

### Log levels

Use the appropriate level for your message:

| Level   | Use when                                      |
| ------- | --------------------------------------------- |
| `Debug` | Verbose details useful during development     |
| `Info`  | Normal operations (resource created, updated) |
| `Warn`  | Recoverable issues (retry, fallback used)     |
| `Error` | Failures that affect operation outcome        |

```go theme={"languages":{"custom":["/languages/pkl.json"]}}
log.Debug("parsing properties", "raw", string(req.Properties))
log.Info("upload started", "path", props.Path)
log.Warn("retrying after timeout", "attempt", 2)
log.Error("failed to connect", "error", err.Error())
```

### Adding context with With()

Create a child logger with additional fields that persist across calls:

```go theme={"languages":{"custom":["/languages/pkl.json"]}}
log := plugin.LoggerFromContext(ctx)
log = log.With("requestID", requestID)

log.Info("operation started")  // includes requestID
log.Debug("step 1 complete")   // includes requestID
```

### Configuring log level

Plugin logs are captured by the agent and follow the agent's logging configuration. See the [Logging section](/documentation/reference/configuration#logging) in the configuration reference to adjust log levels.

## Metrics

The SDK also provides a `MetricRegistry` for recording custom metrics. These are exported via OpenTelemetry when enabled in the agent.

### Getting the MetricRegistry

```go theme={"languages":{"custom":["/languages/pkl.json"]}}
func (p *Plugin) Create(ctx context.Context, req *resource.CreateRequest) (*resource.CreateResult, error) {
    metrics := plugin.MetricsFromContext(ctx)

    // Record a counter
    metrics.Counter("sftp.uploads_started", 1,
        attribute.String("path", props.Path))

    // ... implementation ...
}
```

### Available metric types

The SDK exposes four metric types from [OpenTelemetry's metric model](https://opentelemetry.io/docs/specs/otel/metrics/data-model/):

```go theme={"languages":{"custom":["/languages/pkl.json"]}}
// Counter - monotonically increasing value
metrics.Counter("sftp.uploads_total", 1,
    attribute.String("status", "success"))

// UpDownCounter - value that can increase or decrease
metrics.UpDownCounter("sftp.active_connections", 1)  // connection opened
metrics.UpDownCounter("sftp.active_connections", -1) // connection closed

// Gauge - point-in-time measurement
metrics.Gauge("sftp.queue_depth", float64(len(queue)))

// Histogram - distribution of values (durations, sizes)
start := time.Now()
// ... operation ...
metrics.Histogram("sftp.upload_duration_ms",
    float64(time.Since(start).Milliseconds()))
```

## Complete example

Here's the SFTP plugin's Create method with observability:

```go theme={"languages":{"custom":["/languages/pkl.json"]}}
func (p *Plugin) Create(ctx context.Context, req *resource.CreateRequest) (*resource.CreateResult, error) {
    // Get observability from context
    log := plugin.LoggerFromContext(ctx)
    metrics := plugin.MetricsFromContext(ctx)

    log.Info("creating file resource", "label", req.Label)

    // Parse file properties from request
    props, err := parseFileProperties(req.Properties)
    if err != nil {
        log.Error("invalid properties", "error", err.Error())
        return &resource.CreateResult{
            ProgressResult: &resource.ProgressResult{
                Operation:       resource.OperationCreate,
                OperationStatus: resource.OperationStatusFailure,
                ErrorCode:       resource.OperationErrorCodeInvalidRequest,
                StatusMessage:   err.Error(),
            },
        }, nil
    }

    // ... client setup ...

    // Start async upload
    requestID := client.StartUpload(props.Path, props.Content, perm)

    // Record metric for uploads started
    metrics.Counter("sftp.uploads_started", 1,
        attribute.String("path", props.Path))

    log.Debug("upload started", "requestID", requestID, "path", props.Path)

    return &resource.CreateResult{
        ProgressResult: &resource.ProgressResult{
            Operation:       resource.OperationCreate,
            OperationStatus: resource.OperationStatusInProgress,
            RequestID:       requestID,
            NativeID:        props.Path,
        },
    }, nil
}
```

## No-op fallback

If no logger or metrics are configured (e.g., during unit tests), `LoggerFromContext` and `MetricsFromContext` return no-op implementations that safely ignore all calls. Your code doesn't need nil checks.

***

**Previous:** [13 - Local Testing](/plugin-development/tutorial/13-local-testing) | **Next:** [15 - Real-world plugins](/plugin-development/tutorial/15-real-world-plugins)
