Deleting an image can require thumbnail cleanup, while a slow upload requires timing evidence. Storage exposes these as different observations: events announce successful facade writes and deletions; telemetry measures facade operations. Neither is a durable delivery receipt or a record of every native backend change.
Examples use storage from arkoren.dev/core/service/storage, your
configured driver, and an existing application app.
Connecting Observers
Pass the application’s bus and telemetry when constructing the facade:
files := storage.New(driver, func(store *storage.Builder) {
store.Events(app.Events())
store.Telemetry(app.Telemetry())
store.Name("uploads")
})
The fixed store name labels events and telemetry; omitted or empty names report
default. A name does not select a bucket or provide tenant isolation.
Use a small stable set of names, not user identifiers.
Events require an explicit bus. Without explicit telemetry, each operation uses the shared handle, initially a no-op. Observe through the facade: direct driver or native-client calls bypass its instrumentation and notifications.
Object Events
When your application needs to react to storage changes, subscribe to the typed
topics on the same bus passed to Builder.Events:
| Topic | Payload | Successful Observation |
|---|---|---|
storage.ObjectWritten |
storage.ObjectWrittenEvent |
Put returns metadata after storing an object. |
storage.ObjectDeleted |
storage.ObjectDeletedEvent |
Delete succeeds, including for an absent object. |
The topic names are storage.object.written and storage.object.deleted.
Written payloads contain Store and Object metadata: key, byte size, and
modification time. Deleted payloads contain Store and Key. They contain no
object bytes or signed URLs, but keys can still identify private documents.
For thumbnail cleanup, import standard context. Here thumbnails is your
existing service with Delete(context.Context, string) error, accepting the
source image key:
unsubscribe, err := app.Events().Listen(storage.ObjectDeleted,
func(ctx context.Context, payload storage.ObjectDeletedEvent) error {
if payload.Store != "uploads" {
return nil
}
return thumbnails.Delete(ctx, payload.Key)
})
Check the registration error and retain unsubscribe until the listening
component stops; call it during that component’s cleanup. See the
event bus guide for listener ownership.
Listeners run synchronously before the storage call returns, on its goroutine and with its context. Keep them short; hand slow work to a queue when needed. An error or panic is logged by the bus and stops subsequent listeners for that publication, but never changes the completed storage result. Notifications are not a substitute for durable work: arrange durable reconciliation when missing cleanup would leave important state inconsistent.
PutBytes, PutFile, and PutFileAs reuse Put and publish the same write
event. Failed calls publish nothing, even if replacement succeeded before a
metadata lookup failed. Delete events do not prove the object existed.
Concurrent writes can publish in a different order from backend replacement;
do not reconstruct the current object from event order.
Reads, listings, and signing publish no object event. A client using a signed PUT writes directly to the provider and also publishes none.
Operation Timing
Each facade call records one client span named storage {operation} and two
metrics. Operations are open, put, stat, delete, list, and sign.
Upload helpers reuse put; download helpers reuse open; Sign and SignURL
reuse sign rather than adding helper spans.
open ends when the object is acquired, not after its contents are consumed or
closed. Later download read/write/close failures belong to the caller’s HTTP
operation, not the completed storage span. put includes consuming the source
and backend work, so a slow source can explain a long write duration. sign
measures URL creation, not the client’s later transfer.
Walk records one list per requested page.
Construction records nothing, and there is no separate traversal instrument.
Early stopping avoids observations for pages never requested; S3 read-ahead
inside one driver page remains part of that single facade operation.
The counter arkoren.storage.operations counts calls. The histogram
arkoren.storage.operation.duration records seconds, with buckets from 0.001
to 60 seconds. Both label operation, store, and failure class when present:
| Attribute | Meaning |
|---|---|
arkoren.storage.operation |
The facade operation. |
arkoren.storage.store |
Configured name or default. |
error.type |
Error class on failure. |
arkoren.storage.object.size |
Successful write size in bytes; span only. |
See Instrumented Components for the shared instrument catalog and Telemetry for exporter setup.
Failure Classification
Open or Stat returning storage.ErrNotFound is an ordinary missing-object
outcome: the span is not marked failed and no error.type is set. Other errors
mark the span failed and report their class, not their text.
Known classes include storage.ErrInvalidKey, storage.ErrUnsupported,
storage.ErrInvalidTTL, and storage.ErrInvalidSignOptions. Cancellation uses
the context error class; other errors use their Go type. A permission failure
is not silently reclassified as absence.
If development uses memory storage and intentionally falls back from signing,
expect sign failures classified as storage.ErrUnsupported. Keep this known
capability difference separate from operational alerts; do not hide unexpected
provider failures under the same fallback.
Privacy Boundaries
Automatic storage telemetry excludes keys, prefixes, cursors, signed URLs, payloads, and raw error text. Correlate through a suitably chosen application identifier and trace ID rather than adding a private key to every span. Event payloads include keys for listeners; review destinations before forwarding them.
Facade exclusions do not sanitize native SDK middleware, credential providers,
endpoints, or returned errors. If you add AWS otelaws instrumentation to a
client passed through Native Access, SDK request
spans can nest under facade spans, but follow their own data policies. Review
native attributes and HTTP/body logging separately; never record signed URLs
or credential-bearing endpoints in ordinary diagnostics.
Testing Observations
For a test with t *testing.T, import telemetrytest from
arkoren.dev/core/service/telemetry/telemetrytest, telemetry from
arkoren.dev/core/service/telemetry, and memorystorage from
arkoren.dev/core/service/storage/memorystorage. Construct an isolated store:
fake := telemetrytest.New()
files := storage.New(memorystorage.New(), func(store *storage.Builder) {
store.Telemetry(fake.Telemetry(t))
store.Name("uploads")
})
Write a small object and assert the observation rather than exporting it:
if _, err := files.PutBytes(t.Context(), "reports/a.txt", []byte("ready")); err != nil {
t.Fatal(err)
}
fake.AssertSpan(t, "storage put",
telemetry.String("arkoren.storage.store", "uploads"),
)
fake.AssertCounter(t, "arkoren.storage.operations", 1,
telemetry.String("arkoren.storage.operation", "put"),
)
This checks facade reporting, not backend durability or client transfer success. For events, attach a real in-process bus, capture typed payloads, and assert your listener’s effect; include failure and absent-delete paths when relevant.
For every setting, see the
storage API,
event API, and
telemetry API.