ref:32f9f47b4a4363d75b0ffbdbf5d16f965b792b09

Add telemetry events for core operations (#6)

## Summary - Add `:telemetry` as a direct dependency - Instrument object read/write, ref updates, and receive-pack with `:telemetry.span/3` - All events documented in `ExGitObjectstore.Telemetry` moduledoc Closes #10 ## Events emitted | Event prefix | Metadata | |---|---| | `[:ex_git_objectstore, :object, :read]` | sha, repo_id | | `[:ex_git_objectstore, :object, :write]` | object_type, repo_id, sha | | `[:ex_git_objectstore, :ref, :update]` | ref_path, repo_id, new_sha, old_sha | | `[:ex_git_objectstore, :protocol, :receive_pack]` | repo_id, command_count | Each emits `:start`, `:stop`, and `:exception` suffixed events via `:telemetry.span/3`. ## Test plan - [x] Object write emits start/stop with object_type and sha - [x] Object read emits start/stop with sha - [x] Ref update emits start/stop with ref_path - [x] Receive-pack emits start/stop with command_count - [x] 560 tests pass, dialyzer clean
SHA: 32f9f47b4a4363d75b0ffbdbf5d16f965b792b09
Author: Anvil <noreply@anvil.fangorn.io>
Date: 2026-04-09 20:34
Parents: 3be1166
7 files changed +291 -28
Type
CHANGELOG.md +1 −0
@@ -16,5 +16,6 @@
- `update_hook` and `post_receive_hook` for ReceivePack (per-ref gating, post-push notifications)
- Configurable per-repo `max_object_size` via `Repo.new/2` (default 128MB unchanged)
- CalVer release automation via CI (`ci/release.sh`, `.anvil.yml` release step)
- Telemetry events for object read/write, ref updates, and receive-pack protocol
### Fixed
lib/ex_git_objectstore/object.ex +41 −16
@@ -110,13 +110,22 @@
"""
@spec read(Repo.t(), String.t()) :: {:ok, t()} | {:error, term()}
def read(%Repo{} = repo, sha) do
case Repo.storage_call(repo, :get_object, [sha]) do
{:ok, data} ->
decompress_and_verify(data, sha, repo.max_object_size)
:telemetry.span(
[:ex_git_objectstore, :object, :read],
%{sha: sha, repo_id: repo.id},
fn ->
result =
case Repo.storage_call(repo, :get_object, [sha]) do
{:ok, data} ->
decompress_and_verify(data, sha, repo.max_object_size)
{:error, _} = err ->
err
end
{:error, _} = err ->
{result, %{sha: sha, repo_id: repo.id}}
end
)
err
end
end
@doc """
@@ -124,19 +133,35 @@
"""
@spec write(Repo.t(), t()) :: {:ok, String.t()} | {:error, term()}
def write(%Repo{} = repo, object) do
object_type = object_type_name(object)
:telemetry.span(
[:ex_git_objectstore, :object, :write],
%{object_type: object_type, repo_id: repo.id},
fn ->
sha = hash(object)
sha = hash(object)
result =
case encode(object) do
{:ok, compressed} ->
case Repo.storage_call(repo, :put_object, [sha, compressed]) do
:ok -> {:ok, sha}
{:error, _} = err -> err
end
case encode(object) do
{:ok, compressed} ->
case Repo.storage_call(repo, :put_object, [sha, compressed]) do
:ok -> {:ok, sha}
{:error, _} = err -> err
end
{:error, _} = err ->
err
end
{result, %{object_type: object_type, repo_id: repo.id, sha: sha}}
{:error, _} = err ->
err
end
end
)
end
defp object_type_name(%Blob{}), do: :blob
defp object_type_name(%Commit{}), do: :commit
defp object_type_name(%Tree{}), do: :tree
defp object_type_name(%Tag{}), do: :tag
# Decompress stored data and verify SHA-1 integrity before decoding.
defp decompress_and_verify(data, sha, max_size) do
lib/ex_git_objectstore/protocol/receive_pack.ex +16 −7
@@ -374,14 +374,23 @@
end
defp process_pack_and_refs(state) do
:telemetry.span(
[:ex_git_objectstore, :protocol, :receive_pack],
%{repo_id: state.repo.id, command_count: length(state.commands)},
fn ->
result =
case store_pack_objects(state.repo, state.pack_buffer) do
:ok ->
process_ref_updates(state)
{:error, reason} ->
report = build_error_report(reason, state.commands)
{report, %{state | phase: :done, result: {:error, reason}}}
case store_pack_objects(state.repo, state.pack_buffer) do
:ok ->
process_ref_updates(state)
end
{result, %{repo_id: state.repo.id, command_count: length(state.commands)}}
end
{:error, reason} ->
report = build_error_report(reason, state.commands)
{report, %{state | phase: :done, result: {:error, reason}}}
end
)
end
defp store_pack_objects(repo, pack_data) do
lib/ex_git_objectstore/ref.ex +14 −5
@@ -34,11 +34,20 @@
"""
@spec put(Repo.t(), String.t(), String.t(), String.t() | nil) :: :ok | {:error, term()}
def put(%Repo{} = repo, ref_path, new_sha, old_sha) do
with :ok <- validate_sha(new_sha),
:ok <- validate_ref_name(ref_path),
:ok <- if(old_sha, do: validate_sha(old_sha), else: :ok) do
Repo.storage_call(repo, :put_ref, [ref_path, new_sha, old_sha])
end
:telemetry.span(
[:ex_git_objectstore, :ref, :update],
%{ref_path: ref_path, repo_id: repo.id, new_sha: new_sha, old_sha: old_sha},
fn ->
result =
with :ok <- validate_sha(new_sha),
:ok <- validate_ref_name(ref_path),
:ok <- if(old_sha, do: validate_sha(old_sha), else: :ok) do
Repo.storage_call(repo, :put_ref, [ref_path, new_sha, old_sha])
end
{result, %{ref_path: ref_path, repo_id: repo.id, new_sha: new_sha, old_sha: old_sha}}
end
)
end
@doc """
lib/ex_git_objectstore/telemetry.ex +65 −0
@@ -1,0 +1,65 @@
# Copyright 2026 Cole Christensen
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
defmodule ExGitObjectstore.Telemetry do
@moduledoc """
Telemetry events emitted by ExGitObjectstore.
All events use the `:telemetry.span/3` pattern, emitting `start` and
`stop` (or `exception`) suffixed events automatically.
## Events
### Object Operations
* `[:ex_git_objectstore, :object, :read, :start | :stop | :exception]`
- Measurements: `:system_time` (start), `:duration` (stop)
- Metadata: `:sha`, `:repo_id`
* `[:ex_git_objectstore, :object, :write, :start | :stop | :exception]`
- Measurements: `:system_time` (start), `:duration` (stop)
- Metadata: `:object_type`, `:repo_id`, `:sha` (stop only)
### Ref Operations
* `[:ex_git_objectstore, :ref, :update, :start | :stop | :exception]`
- Measurements: `:system_time` (start), `:duration` (stop)
- Metadata: `:ref_path`, `:repo_id`, `:new_sha`, `:old_sha`
### Pack Operations
* `[:ex_git_objectstore, :pack, :read, :start | :stop | :exception]`
- Measurements: `:system_time` (start), `:duration`, `:object_count` (stop)
- Metadata: `:pack_size`, `:repo_id`
* `[:ex_git_objectstore, :pack, :write, :start | :stop | :exception]`
- Measurements: `:system_time` (start), `:duration`, `:object_count`, `:pack_size` (stop)
- Metadata: `:repo_id`
### Protocol Operations
* `[:ex_git_objectstore, :protocol, :receive_pack, :start | :stop | :exception]`
- Measurements: `:system_time` (start), `:duration`, `:command_count` (stop)
- Metadata: `:repo_id`
* `[:ex_git_objectstore, :protocol, :upload_pack, :start | :stop | :exception]`
- Measurements: `:system_time` (start), `:duration` (stop)
- Metadata: `:repo_id`
"""
@doc false
def span(event_prefix, metadata, fun) do
:telemetry.span(event_prefix, metadata, fun)
end
end
mix.exs +1 −0
@@ -96,6 +96,7 @@
{:sweet_xml, "~> 0.7"},
{:hackney, "~> 1.18"},
{:jason, "~> 1.4"},
{:telemetry, "~> 1.0"},
{:ex_doc, "~> 0.34", only: :dev, runtime: false},
{:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false},
{:credo, "~> 1.7", only: [:dev, :test], runtime: false}
test/ex_git_objectstore/telemetry_test.exs +153 −0
@@ -1,0 +1,153 @@
# Copyright 2026 Cole Christensen
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
defmodule ExGitObjectstore.TelemetryTest do
use ExUnit.Case, async: false
alias ExGitObjectstore.{Object, Ref}
alias ExGitObjectstore.Object.{Blob, Commit, Tree}
alias ExGitObjectstore.Test.RepoHelper
setup do
repo = RepoHelper.memory_repo()
ExGitObjectstore.init(repo)
%{repo: repo}
end
defp attach(event_name) do
test_pid = self()
handler_id = "test-#{inspect(event_name)}-#{System.unique_integer()}"
:telemetry.attach(
handler_id,
event_name,
fn event, measurements, metadata, _config ->
send(test_pid, {:telemetry_event, event, measurements, metadata})
end,
nil
)
on_exit(fn -> :telemetry.detach(handler_id) end)
end
describe "object write telemetry" do
test "emits start and stop events", %{repo: repo} do
attach([:ex_git_objectstore, :object, :write, :start])
attach([:ex_git_objectstore, :object, :write, :stop])
blob = Blob.from_content("telemetry test\n")
{:ok, sha} = Object.write(repo, blob)
assert_received {:telemetry_event, [:ex_git_objectstore, :object, :write, :start],
%{system_time: _}, %{object_type: :blob, repo_id: "test-repo"}}
assert_received {:telemetry_event, [:ex_git_objectstore, :object, :write, :stop],
%{duration: duration}, %{object_type: :blob, sha: ^sha}}
assert is_integer(duration) and duration >= 0
end
end
describe "object read telemetry" do
test "emits start and stop events", %{repo: repo} do
blob = Blob.from_content("read test\n")
{:ok, sha} = Object.write(repo, blob)
attach([:ex_git_objectstore, :object, :read, :start])
attach([:ex_git_objectstore, :object, :read, :stop])
{:ok, _} = Object.read(repo, sha)
assert_received {:telemetry_event, [:ex_git_objectstore, :object, :read, :start],
%{system_time: _}, %{sha: ^sha, repo_id: "test-repo"}}
assert_received {:telemetry_event, [:ex_git_objectstore, :object, :read, :stop],
%{duration: _}, %{sha: ^sha}}
end
end
describe "ref update telemetry" do
test "emits start and stop events", %{repo: repo} do
blob = Blob.from_content("ref test\n")
{:ok, blob_sha} = Object.write(repo, blob)
tree = Tree.new([%{mode: "100644", name: "f.txt", sha: blob_sha}])
{:ok, tree_sha} = Object.write(repo, tree)
commit = %Commit{
tree: tree_sha,
parents: [],
author: "T <t@t> 1 +0000",
committer: "T <t@t> 1 +0000",
message: "m\n"
}
{:ok, sha} = Object.write(repo, commit)
attach([:ex_git_objectstore, :ref, :update, :start])
attach([:ex_git_objectstore, :ref, :update, :stop])
:ok = Ref.put(repo, "refs/heads/main", sha, nil)
assert_received {:telemetry_event, [:ex_git_objectstore, :ref, :update, :start],
%{system_time: _},
%{ref_path: "refs/heads/main", repo_id: "test-repo", new_sha: ^sha}}
assert_received {:telemetry_event, [:ex_git_objectstore, :ref, :update, :stop],
%{duration: _}, %{ref_path: "refs/heads/main"}}
end
end
describe "protocol receive_pack telemetry" do
test "emits start and stop events on push", %{repo: repo} do
alias ExGitObjectstore.Protocol.{PktLine, ReceivePack}
blob = Blob.from_content("push test\n")
{:ok, blob_sha} = Object.write(repo, blob)
tree = Tree.new([%{mode: "100644", name: "f.txt", sha: blob_sha}])
{:ok, tree_sha} = Object.write(repo, tree)
commit = %Commit{
tree: tree_sha,
parents: [],
author: "T <t@t> 1 +0000",
committer: "T <t@t> 1 +0000",
message: "m\n"
}
{:ok, sha} = Object.write(repo, commit)
attach([:ex_git_objectstore, :protocol, :receive_pack, :start])
attach([:ex_git_objectstore, :protocol, :receive_pack, :stop])
zero = String.duplicate("0", 40)
{_advert, state} = ReceivePack.init(repo)
cmd = PktLine.encode("#{zero} #{sha} refs/heads/main\0report-status") <> PktLine.flush()
header = <<"PACK", 2::unsigned-big-32, 0::unsigned-big-32>>
checksum = :crypto.hash(:sha, header)
pack = header <> checksum
{_response, _state} = ReceivePack.feed(state, cmd <> pack)
assert_received {:telemetry_event, [:ex_git_objectstore, :protocol, :receive_pack, :start],
%{system_time: _}, %{repo_id: "test-repo", command_count: 1}}
assert_received {:telemetry_event, [:ex_git_objectstore, :protocol, :receive_pack, :stop],
%{duration: _}, %{repo_id: "test-repo", command_count: 1}}
end
end
end