Skip to content
Get Started

Author a connector

This guide walks through building, testing, and packaging a pz connector: a source and/or sink implementation, plus the manifest handshake that lets pz reject an incompatible package before running any of its code. Read it if you need pz to read from or write to a system none of the fifteen builtin connectors cover.

  • The .NET 10 SDK.
  • Familiarity with pz’s connector vocabulary: connection, entity, builtin versus third-party, native versus universal tier.
  • Know which side you’re building. A source reads into the staging database; a sink writes out of it. A connector may implement one or both.
  1. Set up the project. A connector is an ordinary .NET class library that references Pz.Connectors.Abstractions:

    MyCompany.Pz.Connector.MySource.csproj
    <Project Sdk="Microsoft.NET.Sdk">
    <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    </PropertyGroup>
    <ItemGroup>
    <PackageReference Include="Pz.Connectors.Abstractions" Version="x.y.z" />
    </ItemGroup>
    </Project>

    Pin x.y.z to the version published alongside the pz build you’re targeting: the manifest step below is what catches a mismatch before any of your code runs.

    Every connector implements the shared IConnector surface both ISourceConnector and ISinkConnector extend: Info (a name, version, and the protocol major it speaks), Capabilities, two JSON Schema strings (ConnectionConfigSchema and DatasetConfigSchema) that let the CLI validate a project’s YAML against your connector offline, ValidateAsync for offline cross-field checks, and CheckConnectionAsync for a live connectivity probe.

  2. Implement a minimal source. Implement ISourceConnector and mark your entry point with the assembly attribute the host looks for:

    SourceConnector.cs
    [assembly: PzConnector("mysource", typeof(MySourceConnector))]
    public sealed class MySourceConnector : ISourceConnector
    {
    public ConnectorInfo Info => new("mysource", "1.0.0", ProtocolVersion.Major);
    public ConnectorCapabilities Capabilities => ConnectorCapabilities.None;
    public string ConnectionConfigSchema => "{}";
    public string DatasetConfigSchema => "{}";
    public ValueTask<ValidationResult> ValidateAsync(ConnectorConfig config, CancellationToken ct)
    => ValueTask.FromResult(ValidationResult.Success);
    public ValueTask<ConnectionCheck> CheckConnectionAsync(ConnectorConfig config, CancellationToken ct)
    => ValueTask.FromResult(new ConnectionCheck(true));
    public ValueTask<ISource> OpenAsync(ConnectorConfig config, CancellationToken ct)
    => ValueTask.FromResult<ISource>(new Source());
    // Source implements ISource: GetSchemaAsync, TryGetNativeScan, PlanReadAsync.
    }

    ISource.PlanReadAsync returns one or more IDatasetPartitions, each yielding Arrow RecordBatches from ReadAsync. The full ISource/IDatasetPartition surface is in Connector architecture.

  3. Implement a minimal sink, the same way, against ISinkConnector:

    SinkConnector.cs
    [assembly: PzConnector("mysink", typeof(MySinkConnector))]
    public sealed class MySinkConnector : ISinkConnector
    {
    public ConnectorInfo Info => new("mysink", "1.0.0", ProtocolVersion.Major);
    public ConnectorCapabilities Capabilities => ConnectorCapabilities.None;
    public string ConnectionConfigSchema => "{}";
    public string DatasetConfigSchema => "{}";
    public ValueTask<ValidationResult> ValidateAsync(ConnectorConfig config, CancellationToken ct)
    => ValueTask.FromResult(ValidationResult.Success);
    public ValueTask<ConnectionCheck> CheckConnectionAsync(ConnectorConfig config, CancellationToken ct)
    => ValueTask.FromResult(new ConnectionCheck(true));
    public ValueTask<ISink> OpenAsync(ConnectorConfig config, CancellationToken ct)
    => ValueTask.FromResult<ISink>(new Sink());
    // Sink implements ISink: TryGetNativeCopy, BeginWriteAsync.
    }

    ISink.BeginWriteAsync returns an ISinkWriteSession, which receives batches through WriteBatchAsync and finishes with exactly one of CommitAsync or AbortAsync.

  4. Declare what your connector can do. The planner reads Capabilities, not your code, to decide whether an entity’s declared options are legal before any node runs:

    public ConnectorCapabilities Capabilities =>
    ConnectorCapabilities.Merge | ConnectorCapabilities.ReplaceWrites | ConnectorCapabilities.Transactional;

    Declare only what’s true. A connector that claims Merge without actually handling mode: merge in BeginWriteAsync is a defect the conformance suite (next step) exists to catch. The full capability list and what each flag means for a run’s delivery guarantees is in Connector architecture. A source with no universal read path at all implements the marker interface INativeOnlySource instead of declaring a flag; the SDK derives ConnectorCapabilities.NativeOnlyRead from it for the manifest and the handshake, so pz connector test knows to skip the vectors that need a real PlanRead to succeed.

    A sink can also implement IOutputConfigSchema to get its write:/sink() options validated at pz validate the same way DatasetConfigSchema already validates read options — a JSON Schema for the options your BeginWriteAsync/write session actually reads, excluding the engine-owned keys (strategy, keys, duplicates, on_delete, schema_policy, retry), which never reach it. A sink that skips the interface keeps working exactly as before: its write options simply aren’t checked for typos ahead of a run.

  5. Write the connector manifest. A connector package ships a pz.connector.json file at its root, read before pz spawns or loads anything:

    pz.connector.json
    { "name": "mysource", "protocolMajorMin": 1, "protocolMajorMax": 1,
    "capabilities": ["source"], "runtime": "process",
    "entrypoints": { "linux-x64": "native/pz-mysource" } }

    protocolMajorMin/protocolMajorMax declare the inclusive range of Pz.Connectors.Abstractions protocol majors your connector supports. runtime: "process" plus entrypoints is what makes an external package loadable at all: an external connector with no runtime: "process" manifest, or none at all, is refused with PZ0360 when pz builds its connector registry, before spawning anything. The C# SDK also writes an sdk: { "name": ..., "version": ... } block identifying itself; you never author this by hand (see C# SDK below) — it shows up in pz connectors’ sdk column and in a handshake failure’s message.

    Pack it at the root of the nupkg:

    <ItemGroup>
    <None Include="pz.connector.json" Pack="true" PackagePath="" CopyToOutputDirectory="PreserveNewest" />
    </ItemGroup>

    A manifest that’s present but malformed, such as protocolMajorMin greater than protocolMajorMax, also fails with PZ0306: a broken manifest fails loud rather than silently falling back to the no-manifest path. An optional projectDirectoryAnchor: true key asks pz to resolve the connector’s own relative paths, such as a local cache directory, against the project directory rather than leaving them unanchored.

  6. Run the conformance tests. Pz.Connectors.TestKit ships an acceptance suite that exercises your ISourceConnector/ISinkConnector implementation directly, in-process, as a fast unit test:

    Terminal window
    $ dotnet test

    Once you have a spawnable, process-hosted binary, pz connector test runs the same contract black-box, over the wire, against the real package:

    Terminal window
    $ pz connector test ./dist/my-connector --config probe.yml

    --config names a YAML file with the connection to configure and the read:/write: entity to probe. This is the one pz verb with no --project: it targets a connector package directly, not a project.

    Two of its vectors are worth knowing about ahead of time. numeric-option-fidelity proves a whole number survives the wire round-trip (protobuf’s Struct has only a number type, so an SDK must turn an integral double back into an integer on receipt); it needs no author effort on a current SDK, and reports Skip rather than Fail against an older one. The schema/batch, cancellation, and ticket-handling vectors report Skip, not Fail, against a source that declares NativeOnlyRead (see step 4) instead of demanding a PlanRead result such a source can never give.

  7. Package it as a NuGet package. A connector is an ordinary NuGet package. Tag it pipelinez-connector in <PackageTags>, the ecosystem’s discovery tag, and name it under your own prefix (MyCompany.Pz.Connector.MySource is idiomatic); the bare Pz.* prefix is reserved for first-party packages.

    Terminal window
    $ dotnet pack -c Release
  8. Declare it in a project, and restore it. A project references your package the same way it references any non-builtin connector:

    project.yml
    connectors:
    - package: MyCompany.Pz.Connector.MySource
    version: 1.0.0
    Terminal window
    $ pz restore

    pz restore resolves the package against the host feeds (--feeds, else PZ_FEEDS, else nuget.org), materializes it under .pz/packages, and writes pz.lock.json. pz run and pz validate load from the lock file after that. Commit pz.lock.json; never commit .pz/.

    Confirm it registered, with its declared capabilities, before wiring up a real connections.yml entry against it:

    Terminal window
    $ pz connectors

Pz.Connectors.Sdk serves any Pz.Connectors.Abstractions connector over PCP and packs it as a pz-installable package. Write the connector exactly as steps 1–4 above describe, then:

MyConnector.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<PackageId>Pz.Connector.MySystem</PackageId>
<AssemblyName>pz-mysystem</AssemblyName>
<!-- Restore reads this line, not the SDK's packaging files: it is what pulls the Native AOT
compiler pack. PzPackaging=self-contained turns it back off. -->
<PublishAot>true</PublishAot>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Pz.Connectors.Sdk" Version="x.y.z" />
</ItemGroup>
</Project>
Program.cs
using Pz.Connectors.Sdk;
return await PzConnectorHost.RunAsync(args, new MySystemConnector());
// To log to the pz host: RunAsync(args, ctx => new MySystemConnector(ctx.LoggerFactory))

Rules the SDK enforces so a connector cannot lie to the host:

  • Declare only what you implement. Every optional RPC is answered from the interfaces your objects implement: a source that is not INaturalReadShapeSource answers UNIMPLEMENTED; a partition that is not ISyncStatePartition answers FAILED_PRECONDITION. A SyncState declaration nothing backs fails pz connector test’s sync-state-roundtrip vector.
  • Sync-state tokens are captured by the SDK the moment a partition’s enumeration completes, before end-of-stream is written. Set the candidate anywhere before your iterator returns.
  • Argv is not configuration. --pz-socket <path> serves; --pz-manifest --out <file> [--entrypoint <rid>=<path>]... writes pz.connector.json from the connector object itself (the packaging targets run this for you). Anything else exits 2. Configuration only ever arrives through the Configure RPC.
  • Logging goes to the host as connector log events, rendered verbatim: never log configuration values or payloads.
  • Stdout is discarded; stderr is the diagnostic channel. The host drains and throws away whatever your process writes to stdout — never write your own diagnostics there, since nothing reads them and a connector that writes enough of it with no newline could otherwise block on a full pipe. A bounded tail of stderr is folded into a failure’s message, so an unhandled exception or a crash still surfaces something to pz run’s output. Prefer logging through the SDK (above) over stderr where you can: it reaches the host as a structured, per-connection event rather than free text.
  • Reading a numeric option. ConnectorConfig.GetInt and its siblings turn an integral double within ±2^53 back into an integer; a fractional or non-finite value is refused rather than silently rounded. protobuf’s Struct type has no integer of its own, so this is what makes max_pages: 50000 in YAML arrive as the integer your code expects rather than 50000.0.

An out-of-process connector exports its own traces and metrics to the same collector the engine uses, in the same run trace. When pz run exports telemetry, the host hands its target and the run id to your process at the handshake (HostInfo: otel_endpoint for gRPC, or otel_protocol, otel_traces_endpoint, otel_metrics_endpoint and otel_headers_file for http/protobuf) and puts a W3C traceparent on every RPC. Only then does the SDK:

  • export OTLP with resource service.name=pz-connector, service.version, pz.connector.name, and pz.run.id: over gRPC as before 0.9.1, or over HTTP to each signal’s own URL, gzipped, with delta metrics and exponential histograms, re-reading the headers file before every export. A connector built on an SDK older than 0.9.1 exports nothing under http/protobuf;
  • open a pcp.<Rpc> server span per RPC — every RPC but HostChannel — under the engine’s node.<Kind> span, tagged pz.instance (the connection name for an open the engine drives, else <connector>#<n>), plus a pcp.read_stream/pcp.write_stream span around each data-plane transfer;
  • from SDK 0.10.0, give a failing pcp.<Rpc> span an error status, rpc.grpc.status_code and error.type (the gRPC code name), never the error message;
  • flush on shutdown, bounded to three seconds.

Your own instrumentation joins that trace with no extra wiring — start spans from ctx.ActivitySource and record on ctx.Meter:

return await PzConnectorHost.RunAsync(args, ctx => new MyConnector(ctx.ActivitySource, ctx.Meter));

With no endpoint nothing is built and every span is a BCL no-op. Never put a configuration value in a span name, a tag, or a metric label of your own — what you emit on ctx.ActivitySource and ctx.Meter is exported as written.

To also export a client library’s own ActivitySource/Meter (Npgsql, the AWS SDK), name them in PzConnectorHostOptions.ActivitySources and Meters. Library telemetry fails closed:

  • Only a fixed set of safe attribute keys is exported; every other tag is dropped. The set covers db.system, db.system.name, db.operation.name, db.operation.batch.size, db.namespace, db.collection.name, db.response.status_code, server.address, server.port, network.peer.address, network.peer.port, network.transport, network.type, network.protocol.name, network.protocol.version, http.request.method, http.response.status_code, url.scheme, rpc.system, rpc.service, rpc.method, rpc.grpc.status_code, messaging.system, messaging.operation.name, messaging.operation.type, messaging.destination.name, messaging.batch.message_count, error.type, az.namespace and otel.status_code. Statements, parameters, URLs, headers, paths, ids and exception text are not on it.
  • Hostnames and resource names (server.address, db.namespace, db.collection.name) are exported deliberately; they are what makes the spans useful, so do not point a library at a name you consider secret.
  • A span is dropped, not exported, when it has any event not named exception, an event attribute other than exception.type, or a link that carries attributes. Events and links cannot be edited, and exception messages and event names can carry data values. A failed library call therefore loses its span; your connector’s own pcp.* and remote.* spans keep error.type. Children of a dropped span keep a parent id that points at it.
  • A span’s name is rebuilt as <source> <operation> (the operation is the first of db.operation.name, http.request.method, rpc.method and messaging.operation.name it recorded, at most 96 characters), and its status description is cleared.
  • A Meter named in Meters gets the same attribute allowlist; measurements that differ only by a dropped attribute are aggregated together. Instruments you create yourself on ctx.Meter are not filtered, so their labels must never carry configuration values or data.
  • Only the exact Pz.Connector source and meter are the SDK’s own; a name such as Pz.Connector.Foo is filtered like any library.

Libraries built on Azure.Core emit their ActivitySource only when the AppContext switch Azure.Experimental.EnableActivitySource is set. Set it before creating any client, for example AppContext.SetSwitch("Azure.Experimental.EnableActivitySource", true);, then list the source in ActivitySources.

Since pz 0.11.0 (SDK 0.11.0) a connector can tell pz what its remote system did for a node. The figures land in remote on node_completed and in run_results.json (Run events), and on the node’s span and the pz.remote.* metrics (Observe runs). Declare ConnectorCapabilities.ReportsStats, then report from inside a read or write call through ConnectorStats.Current (a no-op outside an operation, so call it unconditionally):

var stats = ConnectorStats.Current;
stats.AddRequests(1);
stats.AddWireBytes(page.Length);
stats.AddRemoteMs(elapsedMs);
stats.AddThrottled(1); // the remote system said slow down
stats.AddCost(charge, "ru"); // one billing unit per node
stats.AddExtra("cosmos.partitions_scanned", 7); // connector-specific

Reporting never fails a read or write: an invalid value is dropped, and pz prints one note: per run naming the connector and the rule that fired, never the value. The rules:

  • Every number is finite and at least 0.
  • A cost unit is one of ru, bytes_billed, bytes_scanned, credits, dbu, requests, or any token matching [a-z][a-z0-9_]{0,23}. A node keeps the first unit it sees and drops amounts in another; cost is never summed across units.
  • An extra is named <connector>.<what> (lowercase, dotted, [a-z][a-z0-9_]*\.[a-z][a-z0-9_.]*, at most 40 characters). A node keeps at most 8 distinct extras; repeats of a name sum.

Stats are summed across a node’s attempts and partitions. A sink’s stats are read after its write session is disposed, so figures reported while disposing are included. A commit or abort that fails reports no stats for that call, and a partition read abandoned early reports none for that stream.

ctx.Remote does the reporting and the tracing together. ctx.Remote.Start("get_object") opens a remote.get_object client span under the current span and counts one request when you dispose it; chain WireBytes, RemoteMs, Throttled, Cost and Extra on the call. Dispose cannot see an exception, so call call.Fail(ex) in the catch block first (the span records the exception type, never its message):

using var call = ctx.Remote.Start("get_object");
try
{
var response = await client.GetAsync(key, ct);
call.WireBytes(response.Length).Cost(response.Charge, "ru");
}
catch (Exception ex)
{
call.Fail(ex);
throw;
}

The operation is a short fixed verb matching [a-z][a-z0-9_]{0,31} (anything else is recorded as call). For an HttpClient, new HttpClient(ctx.Remote.HttpHandler()) wraps every request in a remote.http span and counts it, adds Content-Length bytes it can see, and counts HTTP 429 and 503 as throttled. It records the method, host, port and status code, and never the URL path or query, headers, bodies or exception messages. The span ends when the response headers arrive, so it times the wait for the response, not the body download, but the bytes of streamed (chunked) request and response bodies are counted as they are sent and read. A synchronous HttpClient.Send is not counted. At most 200 remote.* spans open per operation; further calls still count as requests, and the number skipped is on the node span as pz.remote.spans_dropped.

Spans from a client library you export through PzConnectorHostOptions.ActivitySources are filtered as described under Exporting a client library’s telemetry; the SDK’s own Pz.Connector spans are untouched. In a unit test you can build a RemoteCalls yourself: it has a public constructor taking an ActivitySource.

Name your own instruments <connector>.<what> (cosmos.partitions_scanned), and keep labels to bounded, non-secret values; a label never carries a configuration value, a path, SQL or an identifier from the data.

To test it, Pz.Connectors.TestKit runs Reported_stats_are_valid (sources) and Reported_write_stats_are_valid (sinks) for a connector that declares ReportsStats: they read or write a small dataset with an accumulator in place and fail if any rule above fired. Without the capability they skip. pz connector test adds a commit-stats-valid vector for the same connectors; it commits once more to the probe target, so point write: at a throwaway one.

Native AOT per platform is the default; <PzPackaging>self-contained</PzPackaging> opts a connector whose dependencies cannot be AOT-compiled into a single-file CoreCLR publish instead. The host sees no difference. <PublishAot>true</PublishAot> belongs in the project file itself (or on the command line as -p:PublishAot=true): NuGet restore evaluates a project with every package’s build files excluded, so the compiler pack Native AOT needs is only restored when the project asks for it, never because the SDK’s targets derived it from PzPackaging. A publish that would silently fall back to a CoreCLR layout fails with PZSDK005 instead. self-contained turns PublishAot off again, so the line costs a connector that opts out nothing but a download; switching such a connector to Native AOT from the command line takes both flags (-p:PzPackaging=aot -p:PublishAot=true). Native AOT cannot cross-compile between operating systems, so a release publishes once per platform and packs once:

Terminal window
$ dotnet publish -c Release -r linux-x64 # on Linux
$ dotnet publish -c Release -r osx-arm64 # on macOS
$ dotnet publish -c Release -r win-x64 # on Windows
$ dotnet pack -c Release -p:PzNativeStaging=./stage # once, from a directory holding every RID

pack collects each <staging>/<rid>/ as runtimes/<rid>/native/, runs the packer’s own binary with --pz-manifest to write the manifest (one --entrypoint <rid>=<path> per staged RID), and packs it at the nupkg root. Missing RIDs warn (PZSDK002); no RID at all is an error (PZSDK001); the packing machine must have published its own RID (PZSDK003). Set PzRuntimeIdentifiers to the RID set you ship.

A connector that resolves relative paths from its own configuration (a local file root, a certificate path) sets <PzProjectDirectoryAnchor>true</PzProjectDirectoryAnchor>; the manifest then carries projectDirectoryAnchor, and pz passes the project directory to the connector as the base_dir connection option so those paths resolve against the project rather than the process working directory.

A complete GitHub Actions release workflow:

.github/workflows/release.yml
name: release
on:
push:
tags: ['v*']
jobs:
publish:
strategy:
matrix:
include:
- { os: ubuntu-latest, rid: linux-x64 }
- { os: ubuntu-24.04-arm, rid: linux-arm64 }
- { os: macos-latest, rid: osx-arm64 }
- { os: windows-latest, rid: win-x64 }
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- uses: actions/setup-dotnet@v4
with: { dotnet-version: '10.0.x' }
- run: dotnet publish src/MyConnector -c Release -r ${{ matrix.rid }} -p:PzNativeStaging=${{ github.workspace }}/stage/
- uses: actions/upload-artifact@v4
with:
name: pz-native-${{ matrix.rid }}
path: stage/${{ matrix.rid }}
pack:
needs: publish
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- uses: actions/setup-dotnet@v4
with: { dotnet-version: '10.0.x' }
- uses: actions/download-artifact@v4
with:
pattern: pz-native-*
path: stage
# download-artifact nests each artifact under its name; rename to the RID directories pack expects.
- run: for d in stage/pz-native-*; do mv "$d" "stage/${d#stage/pz-native-}"; done
- run: dotnet publish src/MyConnector -c Release -r linux-x64 -p:PzNativeStaging=${{ github.workspace }}/stage/
- run: dotnet pack src/MyConnector -c Release -p:PzNativeStaging=${{ github.workspace }}/stage/ -o packages
- run: dotnet nuget push packages/*.nupkg --source https://api.nuget.org/v3/index.json --api-key ${{ secrets.NUGET_API_KEY }}

(The pack job republishes linux-x64 because the manifest is produced by running the packer’s own RID; the artifact download covers the others.)

Verify with pz connector test <package-dir> --config probe.yml against the restored package.

The pz-connector crate is an SDK for writing pz’s out-of-process (PCP) connectors in Rust. Its serve_sink entry point parses a --pz-socket argument, serves the connector’s control-plane gRPC service on that AF_UNIX socket, and serves the raw Arrow IPC data plane on a paired socket, dispatching every call to a SinkConnector/Sink/WriteSession you implement. Add the crate as a dependency and implement those three traits; the crate’s own examples/memory_sink.rs is a complete, minimal sink.

A Rust connector builds and runs on Linux and Windows, and on macOS through the same unix code path as Linux. Building the crate needs protoc on PATH (or its path in the PROTOC environment variable), because it compiles the PCP .proto contract at build time. On Windows, take protoc-<version>-win64.zip from the protobuf releases.

Source support is deferred in this SDK. Its trait surface covers sinks only; the wire protocol already covers sources, but no Rust trait exists yet to implement one against.

A minimal Rust sink implements three traits: SinkConnector (validate, check, open, and try_native_copy), Sink (begin_write), and WriteSession (write_batch and the commit/abort pair). It is the same shape as the C# ISinkConnector/ISink/ISinkWriteSession triad above, just in Rust. The crate still produces the same pz.connector.json package shape described in step 5: runtime: "process" with a per-RID entrypoint pointing at the compiled binary.

The Rust SDK exports the same way, with the same resource attributes, span names, and three-second shutdown flush as the C# SDK, on the same instrumentation scope (Pz.Connector). Ordinary tracing spans and events inside your handlers reach the collector; pz_connector::meter() returns the meter to record instruments on. At the handshake the SDK installs its own tracing subscriber. A connector that installs its own subscriber composes pz_connector::layer() into it before serve_sink, so its spans still export:

use tracing_subscriber::layer::SubscriberExt;
tracing::subscriber::set_global_default(
tracing_subscriber::registry()
.with(tracing_subscriber::fmt::layer().with_writer(std::io::stderr))
.with(pz_connector::layer()),
)?;

Without that layer an author-installed subscriber wins and no span is exported (the handshake says so on stderr); meters are unaffected either way.

Since pz 0.11.0 the Rust SDK reports sinks’ remote stats. pz_connector::stats() returns the handle for the write session the calling task runs in (a no-op outside one), with the same methods and the same validation rules as the C# ConnectorStats above, in snake case: add_requests, add_wire_bytes, add_remote_ms, add_throttled, add_cost(units, "ru"), add_extra("vendor.metric", n). pz_connector::remote::start opens a remote.<operation> span and counts a request when the call drops; call fail(&err) first on an error (only the error’s type name is recorded):

let call = pz_connector::remote::start("put_object");
call.wire_bytes(body.len() as i64);
match client.put(&key, body).await {
Ok(resp) => { call.cost(resp.charge, "ru"); }
Err(e) => { call.fail(&e); return Err(e.into()); }
}

The handle is task-local, so it reaches only code awaited inside the SDK’s call; work moved to tokio::spawn reports nowhere. Rust stats cover sinks only (there is no Rust source support), and the Rust SDK does not redact foreign tracing spans.

Two independent checks confirm your connector is ready to ship: dotnet test against the Pz.Connectors.TestKit acceptance suite passes with no skipped mode-honesty facts for the capabilities you declared (including ReportsStats, if you report remote stats), and, once packaged, pz connector test <path> --config probe.yml exits 0 against your real binary.

If you see Do
PZ0360 when pz builds its connector registry Your package’s manifest declares no runtime: "process", or ships no manifest at all. An external connector must be process-hosted; add runtime/entrypoints to pz.connector.json.
PZ0306 The manifest’s protocolMajorMin/protocolMajorMax range doesn’t include the host’s protocol major. Upgrade pz, or pin an older connector version.
PZ0312 at plan time An entity needs the universal tier, but your connector’s TryGetNativeScan/TryGetNativeCopy are the only path it offers. Implement the universal path, or mark the connector native-only with INativeOnlySource/INativeOnlySink.
PZ0304 project.yml declares the package under connectors:, but it isn’t in .pz/packages. Run pz restore.
PZ0307 pz loaded your assembly but found no [assembly: PzConnector(...)] attribute. Check it’s present and names the right implementing type.
PZSDK001/PZSDK003 at pack time No staged binary (or none for the packing machine’s RID). Run dotnet publish -r <rid> before dotnet pack.
PZSDK005 at publish time PzPackaging is aot but the Native AOT compiler pack was never restored, so the publish would have staged a CoreCLR layout under a native binary’s name. Add <PublishAot>true</PublishAot> to the project file and restore again, or opt out with <PzPackaging>self-contained</PzPackaging>.
A PzConnectorException.Message leaks a credential Messages are published verbatim to run_results.json and the event stream. Redact anything sensitive before throwing; see Connector architecture for the full redaction contract.
A capability you declared isn’t honored The conformance suite’s mode-honesty facts exist to catch exactly this. Re-run dotnet test and check which fact failed.