diff --git a/sdks/elixir/sdk/CHANGELOG.md b/sdks/elixir/sdk/CHANGELOG.md index 4f5d5954..d9e4e2c5 100644 --- a/sdks/elixir/sdk/CHANGELOG.md +++ b/sdks/elixir/sdk/CHANGELOG.md @@ -1,5 +1,35 @@ # Changelog +## [Unreleased] + +### Added + +- **MOS-14 device-id contract (SDK layer)** — `Moss.DeviceId` sources a stable, + persisted, per-device id (UUIDv4, no OS vendor id on this platform) and + memoizes it once per client so every telemetry surface (IndexManager, + ManageClient, Session) reports the same id — "one device, one id". Persisted + to `.moss-device-id` under `$XDG_CACHE_HOME/moss` (when set) else + `/.moss` — the same scheme as the JS/Python/Go SDKs so one physical + device resolves to a single id across languages; non-synced/non-migrating; + falls back to an ephemeral id on any persistence error so a real operation + never fails over device-id work. Honors `MOSS_DISABLE_TELEMETRY` (truthy set, + trimmed/lowercased), checked at runtime before the memo fast-path. Threaded + through `Moss.Client.new/3` into all three surfaces (IndexManager, Session, + ManageClient). Contains zero telemetry HTTP/buffer/flush/event code — the + closed core owns transport. + +### Blocked (native binding / CI) + +- Handing the id to the core (setter mechanism, spec R5.2) is **not yet wired**: + the mono-repo Elixir NIF exposes no `set_device_id` entry point. The apply + path degrades gracefully (terminal success) until a `manager_set_device_id` / + `session_set_device_id` / `manage_set_device_id` NIF is added in + `bindings/native/moss_core/src/*` delegating to the core + `set_device_id(Option)`. See the `MOS-14` TODOs in + `Moss.IndexManager`, `Moss.Session`, and `Moss.ManageClient`. + +--- + ## [1.0.1] - 2026-04-05 ### Changed diff --git a/sdks/elixir/sdk/lib/moss/client.ex b/sdks/elixir/sdk/lib/moss/client.ex index 5a63816a..6e7083f0 100644 --- a/sdks/elixir/sdk/lib/moss/client.ex +++ b/sdks/elixir/sdk/lib/moss/client.ex @@ -30,13 +30,14 @@ defmodule Moss.Client do @default_model_id "moss-minilm" - defstruct [:project_id, :project_key, :base_url, :client_id, :manage_ref, :manager_pid] + defstruct [:project_id, :project_key, :base_url, :client_id, :device_id, :manage_ref, :manager_pid] @type t :: %__MODULE__{ project_id: String.t(), project_key: String.t(), base_url: String.t() | nil, client_id: String.t(), + device_id: String.t() | nil, manage_ref: reference(), manager_pid: pid() } @@ -52,13 +53,27 @@ defmodule Moss.Client do base_url = Keyword.get(opts, :base_url, nil) client_id = Uniq.UUID.uuid4() + # MOS-14: source a stable, persisted, per-device id ONCE at client + # construction and thread it through every telemetry surface (the + # IndexManager, ManageClient, and any sessions) so a device counts once — + # "one device, one id" (spec R2.3, R3, R5.5). Sourcing/persistence/opt-out + # all happen here; nil means telemetry is disabled via + # MOSS_DISABLE_TELEMETRY, in which case no id is sourced and nothing is + # handed to the core (R4). + # + # The Elixir SDK has no client cache dir at this layer, so the per-user + # fallback dir (Moss.DeviceId.default_dir/0) is the persistence location. + {device_id, _state} = Moss.DeviceId.resolve_client(Moss.DeviceId.new_state(), nil) + with {:ok, ref} <- Moss.ManageClient.new(project_id, project_key, - base_url: base_url, client_id: client_id), + base_url: base_url, client_id: client_id, + device_id: device_id), {:ok, pid} <- Moss.IndexManager.start_link( project_id: project_id, project_key: project_key, base_url: base_url, - client_id: client_id + client_id: client_id, + device_id: device_id ) do {:ok, %__MODULE__{ @@ -66,6 +81,7 @@ defmodule Moss.Client do project_key: project_key, base_url: base_url, client_id: client_id, + device_id: device_id, manage_ref: ref, manager_pid: pid }} @@ -235,7 +251,10 @@ defmodule Moss.Client do model_id: model_id, project_id: client.project_id, project_key: client.project_key, - client_id: client.client_id + client_id: client.client_id, + # MOS-14: sessions share the client's already-resolved device id + # so every surface of one client reports the same id (R5.5). + device_id: client.device_id ) ) do # Silently attempt to load from cloud — ignore errors (index may not exist yet). diff --git a/sdks/elixir/sdk/lib/moss/device_id.ex b/sdks/elixir/sdk/lib/moss/device_id.ex new file mode 100644 index 00000000..d9f29c98 --- /dev/null +++ b/sdks/elixir/sdk/lib/moss/device_id.ex @@ -0,0 +1,305 @@ +defmodule Moss.DeviceId do + @moduledoc """ + Stable, per-device identifier sourcing for MOS-14 "better tracking" parity. + + This module's ONLY job is to source a stable, persisted, per-device id and + hand it to the closed core, which owns the actual `/telemetry` POST + buffer + + 3s flush. The SDK never touches telemetry transport. + + Contract (see the canonical MOS-14 device-id spec): + + * Elixir is a server/CLI platform with no OS-blessed vendor id, so the id + is a generated UUIDv4 persisted on first use (spec R1.2). There is no + Apple `identifierForVendor` equivalent here. + * The id is an opaque string handed through unchanged (R1.3). A blank + persisted value is treated as absent and regenerated (R1.4). + * Persistence (R2.2, file-platform class): a plaintext file named exactly + `.moss-device-id`. When a client cache dir is known it lives at + `/.moss-device-id`; otherwise it falls back to a single + per-user dir so a device counts once toward Monthly Active Devices + (R2.3). The per-user dir mirrors the naming intent `dev.moss.sdk` / + account `device_id` from the Keychain platforms by using a stable + `moss` user-cache dir plus a `.moss` fallback. + * The store is device-scoped and MUST NOT sync/migrate to another device + (R2). A user-cache / home dir is non-synced by construction. + * Persistence failure must never break the client: on any error we fall + back to a fresh ephemeral (non-persisted) UUID (R2.4). + * `MOSS_DISABLE_TELEMETRY` (truthy set, trimmed + lowercased) is honored, + checked before the memo fast-path, at runtime: when disabled we source + no id, do no store I/O, and hand nothing to the core (R4). + + Memoization (R3) and "apply once" tracking are held per-client in the + GenServer/struct state that calls this module; this module is a pure + resolver plus a best-effort apply helper. + + NOTE (native binding gap): the mono-repo Elixir NIF does not yet expose a + `set_device_id` entry point (setter mechanism, R5.2). Until that NIF exists, + `apply/2` degrades gracefully (R5.4). See `Moss.DeviceId.apply/2` and the + `MOS-14` TODOs in `Moss.IndexManager` / `Moss.Session` / `Moss.ManageClient`. + """ + + require Logger + + # This module defines a local `apply/2` (best-effort setter invocation); it + # deliberately shadows Kernel.apply/2, which is not used here. + import Kernel, except: [apply: 2] + + @device_id_file ".moss-device-id" + @fallback_dir_name ".moss" + @user_cache_app "moss" + @truthy ~w(1 true yes on) + + @typedoc "Per-client device-id memo state: the resolved id and whether it was applied to the core." + @type state :: %{optional(:id) => String.t() | nil, applied: boolean()} + + @doc """ + True when usage telemetry is disabled via `MOSS_DISABLE_TELEMETRY`. + + Truthy set (trimmed + lowercased): `#{inspect(@truthy)}`. Checked at runtime + so toggling the env var mid-process takes effect immediately (R4.2). + """ + @spec telemetry_disabled?() :: boolean() + def telemetry_disabled?, do: telemetry_disabled?(System.get_env()) + + @doc false + @spec telemetry_disabled?(map()) :: boolean() + def telemetry_disabled?(env) when is_map(env) do + case Map.get(env, "MOSS_DISABLE_TELEMETRY") do + nil -> false + v -> v |> to_string() |> String.trim() |> String.downcase() |> Kernel.in(@truthy) + end + end + + @doc """ + Per-user fallback directory for the device-id file, used when no `cache_path` + is available (e.g. the session API, which has no cache dir of its own). + + Resolves to a stable, per-user, non-synced location so a device's id is the + same across processes and surfaces — it counts once toward Monthly Active + Devices (R2.3). Uses `$XDG_CACHE_HOME/moss` when that env var is set, + falling back to `/.moss` where `home` = `$HOME` -> `%USERPROFILE%` -> + OS home, with blank values skipped so a blank `$HOME` does not resolve into + the CWD (R2.2). + """ + @spec default_dir() :: String.t() + def default_dir, do: default_dir(System.get_env()) + + @doc false + @spec default_dir(map()) :: String.t() + def default_dir(env) when is_map(env) do + case xdg_cache_home(env) do + dir when is_binary(dir) -> Path.join(dir, @user_cache_app) + _ -> Path.join(home_dir(env), @fallback_dir_name) + end + end + + @doc """ + Resolve the stable per-device id persisted at `/.moss-device-id`. + + Reads an existing non-blank UUID, or generates and writes one. Returns `nil` + when telemetry is disabled (no store I/O). On any filesystem error, returns a + fresh ephemeral UUID (NOT persisted) so telemetry can still attribute within + this run — device-id persistence must never break a real operation (R2.4). + """ + @spec resolve(String.t()) :: String.t() | nil + def resolve(dir), do: resolve(dir, System.get_env()) + + @doc false + @spec resolve(String.t(), map()) :: String.t() | nil + def resolve(dir, env) when is_binary(dir) and is_map(env) do + if telemetry_disabled?(env) do + nil + else + do_resolve(Path.expand(dir)) + end + end + + defp do_resolve(dir) do + file = Path.join(dir, @device_id_file) + + case read_existing(file) do + {:ok, existing} -> + existing + + :none -> + write_new(dir, file) + end + rescue + # Any unexpected error must not break the client: fall back to an ephemeral id. + e -> + Logger.debug("Moss.DeviceId: persistence failed (#{inspect(e)}); using ephemeral id") + generate_uuid() + end + + defp read_existing(file) do + case File.read(file) do + {:ok, contents} -> + trimmed = String.trim(contents) + # A persisted value that reads back empty is treated as absent (R1.4). + if trimmed == "", do: :none, else: {:ok, trimmed} + + {:error, _} -> + :none + end + end + + defp write_new(dir, file) do + id = generate_uuid() + + case File.mkdir_p(dir) do + :ok -> + case File.write(file, id) do + :ok -> id + # Write failed: still return the (ephemeral) id rather than crashing. + {:error, _} -> id + end + + {:error, _} -> + id + end + end + + @doc """ + Resolve the client's device id ONCE and memoize it on `state`, so every + telemetry surface a client touches (the IndexManager, ManageClient, and any + sessions) reports the same id — one device, one id (R3.1, R5.5). + + Persists under `cache_path` when a non-blank one is given, otherwise under + `default_dir/0`. Returns `{id_or_nil, new_state}`. Returns `nil` (without + memoizing) when telemetry is disabled, and — because the disabled-check runs + before the memo fast-path — a runtime opt-out takes effect immediately even + after an id was memoized (R3.3, R4.2). + """ + @spec resolve_client(state(), String.t() | nil) :: {String.t() | nil, state()} + def resolve_client(state, cache_path), do: resolve_client(state, cache_path, System.get_env()) + + @doc false + @spec resolve_client(state(), String.t() | nil, map()) :: {String.t() | nil, state()} + def resolve_client(state, cache_path, env) when is_map(state) and is_map(env) do + cond do + # Disabled-check first, before the memo fast-path (R4.2). + telemetry_disabled?(env) -> + {nil, state} + + is_binary(Map.get(state, :id)) -> + {state.id, state} + + true -> + dir = + case cache_path do + p when is_binary(p) -> if String.trim(p) == "", do: default_dir(env), else: p + _ -> default_dir(env) + end + + case resolve(dir, env) do + nil -> {nil, state} + id -> {id, Map.put(state, :id, id)} + end + end + end + + @doc """ + Best-effort push of `id` to the core via `apply_fun`, which should call the + binding's setter NIF (setter mechanism, R5.2). Never raises (R5.3). + + Returns `true` when the id is now settled — on success, OR when the setter is + not available in this build (an older/mono NIF that predates the device-id + entry point: terminal, nothing to retry, R5.4). Returns `false` only when the + apply raised, so the caller may retry later (R3.3). + + `apply_fun` is an arity-1 function returning one of: + + * `:ok` / `{:ok, _}` -> success + * `:unsupported` -> setter not present in this build (terminal success) + * `{:error, _}` / raises -> transient failure (retry) + + Passing `nil` (no setter wired at all) is treated as `:unsupported`, so the + SDK-layer sourcing/persistence is fully exercisable and MOS-14-compliant now, + and flips to real applies the moment the `set_device_id` NIF lands. + """ + @spec apply((String.t() -> term()) | nil, String.t()) :: boolean() + def apply(nil, _id), do: true + + def apply(apply_fun, id) when is_function(apply_fun, 1) and is_binary(id) do + case apply_fun.(id) do + :ok -> true + {:ok, _} -> true + :unsupported -> true + _ -> false + end + rescue + e -> + Logger.debug("Moss.DeviceId: apply failed (#{inspect(e)}); will retry") + false + end + + @doc """ + Resolve the device id (once, shared via `state`) and push it to the core via + `apply_fun`. No-op once applied or when telemetry is disabled (R3.2). + + On a transient apply failure `state.applied` stays `false` so the next call + retries rather than permanently suppressing the id (R3.3). Returns the new + `state`. + """ + @spec apply_once(state(), (String.t() -> term()) | nil, String.t() | nil) :: state() + def apply_once(state, apply_fun, cache_path), + do: apply_once(state, apply_fun, cache_path, System.get_env()) + + @doc false + @spec apply_once(state(), (String.t() -> term()) | nil, String.t() | nil, map()) :: state() + def apply_once(state, apply_fun, cache_path, env) when is_map(state) and is_map(env) do + if Map.get(state, :applied, false) do + state + else + {id, state} = resolve_client(state, cache_path, env) + + case id do + nil -> state + _ -> Map.put(state, :applied, apply(apply_fun, id)) + end + end + end + + @doc "A fresh, empty per-client memo state." + @spec new_state() :: state() + def new_state, do: %{id: nil, applied: false} + + # --------------------------------------------------------------------------- + # Private helpers + # --------------------------------------------------------------------------- + + # `||` semantics: blank ("" after trim) $HOME / %USERPROFILE% must fall + # through to the OS home rather than producing a relative ".moss" that would + # land in the CWD (R2.2). + defp home_dir(env) do + trimmed = fn key -> + case Map.get(env, key) do + v when is_binary(v) -> + t = String.trim(v) + if t == "", do: nil, else: t + + _ -> + nil + end + end + + trimmed.("HOME") || trimmed.("USERPROFILE") || System.user_home!() || System.tmp_dir!() + end + + # Honors $XDG_CACHE_HOME from the (injectable) env for testability and Linux + # convention; blank/absent falls through to /.moss (R2.2). + defp xdg_cache_home(env) do + case Map.get(env, "XDG_CACHE_HOME") do + v when is_binary(v) -> + t = String.trim(v) + if t == "", do: nil, else: t + + _ -> + nil + end + end + + # UUIDv4, opaque string, returned as-is (R1.2, R1.3). Uses the same `uniq` + # dep the SDK already relies on for client_id. + defp generate_uuid, do: Uniq.UUID.uuid4() +end diff --git a/sdks/elixir/sdk/lib/moss/index_manager.ex b/sdks/elixir/sdk/lib/moss/index_manager.ex index 53740718..190be2b0 100644 --- a/sdks/elixir/sdk/lib/moss/index_manager.ex +++ b/sdks/elixir/sdk/lib/moss/index_manager.ex @@ -114,13 +114,50 @@ defmodule Moss.IndexManager do project_key = Keyword.fetch!(opts, :project_key) base_url = Keyword.get(opts, :base_url, nil) client_id = Keyword.get(opts, :client_id, nil) + device_id = Keyword.get(opts, :device_id, nil) case Nif.manager_new(project_id, project_key, base_url, client_id) do - {:ok, ref} -> {:ok, %{ref: ref}} - {:error, reason} -> {:stop, reason} + {:ok, ref} -> + {:ok, apply_device_id(%{ref: ref}, device_id)} + + {:error, reason} -> + {:stop, reason} end end + # MOS-14: hand the stable per-device id to the core (setter mechanism, R5.2). + # + # BLOCKED ON NATIVE BINDING: the mono-repo Elixir NIF does not yet expose a + # `set_device_id` entry point. The device-id-capable core setter exists + # (`moss::manager::IndexManager::set_device_id(Option)`), but no NIF + # surfaces it. Until the NIF lands, this degrades gracefully (R5.4): the id is + # still sourced/persisted/memoized at the SDK layer, just not pushed to the + # core. `apply_fun: nil` -> Moss.DeviceId.apply/2 returns terminal success. + # + # WHEN THE NIF LANDS, add to MossCore.Nif: + # def manager_set_device_id(_ref, _device_id), do: :erlang.nif_error(:not_loaded) + # and to bindings/native/moss_core/src/manager.rs: + # #[rustler::nif] + # pub fn manager_set_device_id(resource: ResourceArc, + # device_id: Option) -> rustler::Atom { + # resource.inner.lock().unwrap().set_device_id(device_id); + # ok() + # } + # then swap the `nil` below for: + # fn -> id -> Nif.manager_set_device_id(state.ref, id) end + # returning :ok so Moss.DeviceId.apply/2 reports success. + defp apply_device_id(%{ref: _ref} = state, nil) do + # Telemetry disabled (no id sourced): nothing to apply. + Map.put(state, :device_id_state, Moss.DeviceId.new_state()) + end + + defp apply_device_id(%{ref: _ref} = state, device_id) when is_binary(device_id) do + dev_state = %{id: device_id, applied: false} + # apply_fun is nil until the set_device_id NIF exists (see note above). + dev_state = Moss.DeviceId.apply_once(dev_state, nil, nil) + Map.put(state, :device_id_state, dev_state) + end + @impl true def handle_call({:load_index, index_name, auto_refresh, polling_interval}, _from, state) do reply = diff --git a/sdks/elixir/sdk/lib/moss/manage_client.ex b/sdks/elixir/sdk/lib/moss/manage_client.ex index 58053f43..d07b616e 100644 --- a/sdks/elixir/sdk/lib/moss/manage_client.ex +++ b/sdks/elixir/sdk/lib/moss/manage_client.ex @@ -14,7 +14,31 @@ defmodule Moss.ManageClient do def new(project_id, project_key, opts \\ []) do base_url = Keyword.get(opts, :base_url, nil) client_id = Keyword.get(opts, :client_id, nil) - Nif.manage_new(project_id, project_key, base_url, client_id) + device_id = Keyword.get(opts, :device_id, nil) + + case Nif.manage_new(project_id, project_key, base_url, client_id) do + {:ok, ref} = ok -> + apply_device_id(ref, device_id) + ok + + other -> + other + end + end + + # MOS-14: hand the stable per-device id to the core (setter mechanism, R5.2). + # + # BLOCKED ON NATIVE BINDING: no `set_device_id` NIF exists for the manage + # resource yet. Until it lands this degrades gracefully (R5.4): id is still + # sourced/persisted/shared at the SDK layer, just not pushed to the core + # (`apply_fun: nil` -> terminal success). See Moss.IndexManager for the exact + # NIF + Rust change to add; the manage analogue is `manage_set_device_id` + # delegating to `ManageClient::set_device_id`. + defp apply_device_id(_ref, nil), do: :ok + + defp apply_device_id(_ref, device_id) when is_binary(device_id) do + _ = Moss.DeviceId.apply_once(%{id: device_id, applied: false}, nil, nil) + :ok end @doc "Create a cloud index with initial documents." diff --git a/sdks/elixir/sdk/lib/moss/session.ex b/sdks/elixir/sdk/lib/moss/session.ex index ee3cfe5c..b6623581 100644 --- a/sdks/elixir/sdk/lib/moss/session.ex +++ b/sdks/elixir/sdk/lib/moss/session.ex @@ -153,13 +153,42 @@ defmodule Moss.Session do project_id = Keyword.fetch!(opts, :project_id) project_key = Keyword.fetch!(opts, :project_key) client_id = Keyword.get(opts, :client_id, nil) + device_id = Keyword.get(opts, :device_id, nil) case Nif.session_new(name, model_id, project_id, project_key, client_id) do - {:ok, ref} -> {:ok, %{ref: ref, model_id: model_id}} + {:ok, ref} -> {:ok, apply_device_id(%{ref: ref, model_id: model_id}, device_id)} {:error, reason} -> {:stop, reason} end end + # MOS-14: hand the client's shared per-device id to this session surface + # (setter mechanism, R5.2, R5.5). Every session opened from a client reports + # the SAME id the IndexManager/ManageClient use. + # + # BLOCKED ON NATIVE BINDING: no `set_device_id` NIF exists for the session + # resource yet. The internal core surfaces it + # (`moss::session::SessionIndex::set_device_id`), but the mono Elixir NIF does + # not. Until then this degrades gracefully (R5.4): id is still sourced/shared, + # just not pushed to the core (`apply_fun: nil` -> terminal success). + # + # WHEN THE NIF LANDS, add to MossCore.Nif: + # def session_set_device_id(_ref, _device_id), do: :erlang.nif_error(:not_loaded) + # and to bindings/native/moss_core/src/session.rs: + # #[rustler::nif] + # pub fn session_set_device_id(resource: ResourceArc, + # device_id: Option) -> rustler::Atom { + # resource.inner.lock().unwrap().set_device_id(device_id); + # ok() + # } + # then swap the `nil` below for: + # fn -> id -> Nif.session_set_device_id(state.ref, id) end + defp apply_device_id(%{ref: _ref} = state, nil), do: state + + defp apply_device_id(%{ref: _ref} = state, device_id) when is_binary(device_id) do + _ = Moss.DeviceId.apply_once(%{id: device_id, applied: false}, nil, nil) + state + end + @impl true def handle_call(:doc_count, _from, state) do {:reply, Nif.session_doc_count(state.ref), state} diff --git a/sdks/elixir/sdk/test/moss/device_id_test.exs b/sdks/elixir/sdk/test/moss/device_id_test.exs new file mode 100644 index 00000000..069c62a1 --- /dev/null +++ b/sdks/elixir/sdk/test/moss/device_id_test.exs @@ -0,0 +1,167 @@ +defmodule Moss.DeviceIdTest do + @moduledoc """ + SDK-layer tests for the MOS-14 device-id contract. These exercise + sourcing / persistence / memoization / opt-out / best-effort apply against a + fake apply function — exactly as the TS reference `deviceId.test.ts` uses a + fake `setDeviceId` target — so the contract is verifiable independently of + the (not-yet-existing) `set_device_id` NIF. + """ + use ExUnit.Case, async: true + + alias Moss.DeviceId + + @uuid_re ~r/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + + setup do + dir = Path.join(System.tmp_dir!(), "moss-devid-#{System.unique_integer([:positive])}") + File.mkdir_p!(dir) + on_exit(fn -> File.rm_rf(dir) end) + {:ok, dir: dir} + end + + # --- resolve/2 (R1, R2.2, R2.4) --- + + test "creates and persists a UUID on first resolve", %{dir: dir} do + id = DeviceId.resolve(dir, %{}) + assert id =~ @uuid_re + file = Path.join(dir, ".moss-device-id") + assert File.exists?(file) + assert File.read!(file) |> String.trim() == id + end + + test "returns the same id on subsequent resolves", %{dir: dir} do + first = DeviceId.resolve(dir, %{}) + assert DeviceId.resolve(dir, %{}) == first + end + + test "honors a pre-seeded id file", %{dir: dir} do + File.write!(Path.join(dir, ".moss-device-id"), "preseeded-id-1") + assert DeviceId.resolve(dir, %{}) == "preseeded-id-1" + end + + test "treats a blank persisted value as absent and regenerates", %{dir: dir} do + File.write!(Path.join(dir, ".moss-device-id"), " \n") + id = DeviceId.resolve(dir, %{}) + assert id =~ @uuid_re + end + + test "returns nil when telemetry is disabled and writes nothing", %{dir: dir} do + assert DeviceId.resolve(dir, %{"MOSS_DISABLE_TELEMETRY" => "true"}) == nil + refute File.exists?(Path.join(dir, ".moss-device-id")) + end + + # --- telemetry_disabled?/1 (R4.1) --- + + test "telemetry_disabled? parses common truthy values" do + assert DeviceId.telemetry_disabled?(%{"MOSS_DISABLE_TELEMETRY" => "1"}) + assert DeviceId.telemetry_disabled?(%{"MOSS_DISABLE_TELEMETRY" => "TRUE"}) + assert DeviceId.telemetry_disabled?(%{"MOSS_DISABLE_TELEMETRY" => " Yes "}) + refute DeviceId.telemetry_disabled?(%{}) + refute DeviceId.telemetry_disabled?(%{"MOSS_DISABLE_TELEMETRY" => "0"}) + end + + # --- resolve_client/3 (R3, R2.3) --- + + test "resolve_client persists under cache_path when provided", %{dir: dir} do + {id, state} = DeviceId.resolve_client(DeviceId.new_state(), dir, %{}) + assert id =~ @uuid_re + assert File.read!(Path.join(dir, ".moss-device-id")) |> String.trim() == id + assert state.id == id + end + + test "resolve_client memoizes the first id across calls and locations", %{dir: dir} do + {first, state} = DeviceId.resolve_client(DeviceId.new_state(), dir, %{}) + # A later call with no cache_path must reuse the memoized id — one device, one id. + {second, _state} = + DeviceId.resolve_client(state, nil, %{"HOME" => Path.join(dir, "other")}) + + assert second == first + refute File.exists?(Path.join([dir, "other", ".moss", ".moss-device-id"])) + end + + test "resolve_client returns nil and does not memoize when telemetry disabled", %{dir: dir} do + {id, state} = + DeviceId.resolve_client(DeviceId.new_state(), dir, %{"MOSS_DISABLE_TELEMETRY" => "1"}) + + assert id == nil + assert state.id == nil + end + + test "resolve_client honors runtime disable even after an id was memoized", %{dir: dir} do + state = %{id: "preset-id", applied: true} + {id, _} = DeviceId.resolve_client(state, dir, %{"MOSS_DISABLE_TELEMETRY" => "1"}) + assert id == nil + end + + test "resolve_client treats a blank cache_path as absent, uses fallback dir", %{dir: dir} do + {id, _} = DeviceId.resolve_client(DeviceId.new_state(), " ", %{"HOME" => dir}) + assert id =~ @uuid_re + # persisted under the fallback dir, not the CWD + assert File.read!(Path.join([dir, ".moss", ".moss-device-id"])) |> String.trim() == id + end + + # --- apply/2 (R5.3, R5.4) --- + + test "apply pushes the id to the target and reports success" do + parent = self() + ok? = DeviceId.apply(fn id -> send(parent, {:applied, id}) && :ok end, "abc") + assert ok? + assert_received {:applied, "abc"} + end + + test "apply treats a nil apply_fun (older/absent binding) as terminal success" do + assert DeviceId.apply(nil, "abc") + end + + test "apply treats :unsupported as terminal success" do + assert DeviceId.apply(fn _ -> :unsupported end, "abc") + end + + test "apply reports failure when apply_fun raises (so caller can retry)" do + refute DeviceId.apply(fn _ -> raise "transient binding error" end, "abc") + end + + test "apply reports failure on {:error, _}" do + refute DeviceId.apply(fn _ -> {:error, :nope} end, "abc") + end + + # --- apply_once/4 (R3.2, R3.3) --- + + test "apply_once sets the id exactly once and memoizes", %{dir: dir} do + parent = self() + fun = fn id -> send(parent, {:call, id}) && :ok end + + state = DeviceId.apply_once(DeviceId.new_state(), fun, dir, %{}) + state = DeviceId.apply_once(state, fun, dir, %{}) + + assert state.applied + assert_received {:call, id} + assert id =~ @uuid_re + refute_received {:call, _} + end + + test "apply_once does nothing when telemetry disabled", %{dir: dir} do + parent = self() + fun = fn id -> send(parent, {:call, id}) && :ok end + state = DeviceId.apply_once(DeviceId.new_state(), fun, dir, %{"MOSS_DISABLE_TELEMETRY" => "yes"}) + refute state.applied + refute_received {:call, _} + end + + test "apply_once marks applied without raising when apply_fun is nil", %{dir: dir} do + state = DeviceId.apply_once(DeviceId.new_state(), nil, dir, %{}) + assert state.applied + end + + test "apply_once leaves applied=false (retries) when apply_fun raises", %{dir: dir} do + parent = self() + fun = fn _ -> send(parent, :called) && raise("transient") end + + state = DeviceId.apply_once(DeviceId.new_state(), fun, dir, %{}) + refute state.applied + _ = DeviceId.apply_once(state, fun, dir, %{}) + # called twice: retried on the second call + assert_received :called + assert_received :called + end +end diff --git a/sdks/go/CHANGELOG.md b/sdks/go/CHANGELOG.md new file mode 100644 index 00000000..c334e5f6 --- /dev/null +++ b/sdks/go/CHANGELOG.md @@ -0,0 +1,34 @@ +# Changelog + +All notable changes to the Moss Go SDK are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). + +## [Unreleased] + +### Added + +- Device-id contract (MOS-14 "better tracking" parity). The SDK now sources a + stable, persisted, per-device id and hands it to the core at construction via + the device-id constructor (`moss_client_new_with_device_id`). Both + `NewIndexManager` and `NewManageClient` route through this path. + - The id is a UUIDv4 persisted in a plaintext file `.moss-device-id` under + `$XDG_CACHE_HOME/moss` (when set) else `/.moss` — a non-synced, + per-user, per-device location. This is the same scheme as the + JS/Python/Elixir SDKs, so one physical device resolves to a single id + across languages (the metric this contract keeps accurate). + - Honors `MOSS_DISABLE_TELEMETRY` (truthy set `{1,true,yes,on}`, trimmed and + lowercased): when disabled, no id is sourced, no store I/O happens, and the + plain `moss_client_new` constructor is used instead. + - Memoized once per process; persistence failures fall back to an ephemeral + (non-persisted) UUID and never fail client construction. + - The SDK contains no telemetry HTTP/buffer/flush/event-composition code; the + closed core owns transport. + +### Notes + +- The device-id constructor requires a `libmoss` build whose header declares + `moss_client_new_with_device_id`. The mono repo does not vendor `libmoss.h` + (it is supplied at build time), so linking against an older `libmoss` that + predates the device-id ABI will fail at cgo compile time. See + `bindings/libmoss.go` for details. diff --git a/sdks/go/LICENSE b/sdks/go/LICENSE new file mode 100644 index 00000000..f536c684 --- /dev/null +++ b/sdks/go/LICENSE @@ -0,0 +1,24 @@ +BSD 2-Clause License + +Copyright (c) 2025 InferEdge Inc. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/sdks/go/bindings/deviceid.go b/sdks/go/bindings/deviceid.go new file mode 100644 index 00000000..cbd80e7d --- /dev/null +++ b/sdks/go/bindings/deviceid.go @@ -0,0 +1,169 @@ +package mosscore + +// Device-id sourcing for MOS-14 "better tracking" parity. +// +// The closed Moss core owns the actual /telemetry POST, buffering, and 3s +// flush. This file's ONLY job is to source a stable, persisted, per-device id +// and hand it to the core through the native binding's device-id entry point +// (the constructor mechanism, R5.1 of the device-id contract). It contains +// zero telemetry HTTP/buffer/flush/event-composition code (N6). +// +// Go has no OS-blessed per-vendor id (R1.1 does not apply), so the id is a +// generated UUIDv4 persisted on first use (R1.2), stored in a plaintext file +// in a non-synced, per-user location (R2): $XDG_CACHE_HOME/moss/.moss-device-id +// when that env var is set, else /.moss/.moss-device-id — the SAME scheme +// as the JS/Python/Elixir SDKs so one physical device resolves to a single id +// across languages (R2.3), the metric this contract exists to keep accurate. +// +// Reference implementations: +// - Swift (constructor mechanism, Keychain persistence): +// moss/sdks/swift/Sources/Moss/MossClient.swift stableDeviceId() 203-247. +// - TypeScript (file persistence + opt-out semantics): +// moss-sdks-internal/javascript/user-facing-sdk/src/utils/deviceId.ts. + +import ( + "crypto/rand" + "encoding/hex" + "os" + "path/filepath" + "strings" + "sync" +) + +const ( + // deviceIDDirName is the per-user subdirectory (under ) that holds + // the device-id file, matching the JS/Python/Elixir SDKs so a single + // physical device resolves to ONE id across languages (R2.3). + deviceIDDirName = ".moss" + // deviceIDCacheApp is the subdirectory used under $XDG_CACHE_HOME when set. + deviceIDCacheApp = "moss" + // deviceIDFileName is the plaintext file holding the persisted UUID, + // identical to the other SDKs' ".moss-device-id". + deviceIDFileName = ".moss-device-id" + // disableTelemetryEnv, when truthy, opts the process out entirely: no id + // is sourced, no store I/O happens, and no device-id ctor is called. + // (R4.1, deviceId.ts:8-14.) + disableTelemetryEnv = "MOSS_DISABLE_TELEMETRY" +) + +// truthyTelemetryDisable is the set of values (trimmed + lowercased) that count +// as "telemetry disabled". Mirrors deviceId.ts:8 (`{"1","true","yes","on"}`). +var truthyTelemetryDisable = map[string]bool{ + "1": true, + "true": true, + "yes": true, + "on": true, +} + +// telemetryDisabled reports whether MOSS_DISABLE_TELEMETRY is set to a truthy +// value (trimmed, lowercased). Checked at runtime before sourcing (R4.1/R4.2). +func telemetryDisabled() bool { + return truthyTelemetryDisable[strings.ToLower(strings.TrimSpace(os.Getenv(disableTelemetryEnv)))] +} + +var ( + deviceIDOnce sync.Once + deviceIDCache string + deviceIDIsSet bool +) + +// stableDeviceID resolves this device's stable id, memoized once per process +// (R3.1). Returns ("", false) when telemetry is disabled (R4.1) — callers must +// then use the non-device-id constructor. Returns (id, true) otherwise. +// +// Because the disable check must take effect at runtime (R4.2), it runs BEFORE +// the memo fast-path: toggling the env var mid-process immediately stops +// attribution even after an id was memoized. +func stableDeviceID() (string, bool) { + if telemetryDisabled() { + return "", false + } + deviceIDOnce.Do(func() { + deviceIDCache = resolveDeviceID() + deviceIDIsSet = deviceIDCache != "" + }) + return deviceIDCache, deviceIDIsSet +} + +// resolveDeviceID reads an existing persisted UUID or generates and persists a +// new one. On any persistence error it falls back to a fresh ephemeral +// (non-persisted) UUID so client construction never fails over device-id +// plumbing (R2.4, deviceId.ts:39-41). An empty/blank stored value is treated +// as absent and regenerated (R1.4, MossClient.swift:228-230). +func resolveDeviceID() string { + dir, err := deviceIDDir() + if err != nil { + return newUUID() + } + file := filepath.Join(dir, deviceIDFileName) + + if data, err := os.ReadFile(file); err == nil { + if existing := strings.TrimSpace(string(data)); existing != "" { + return existing + } + } + + id := newUUID() + if err := os.MkdirAll(dir, 0o700); err != nil { + return id // ephemeral: persistence failed, but never break the client + } + if err := os.WriteFile(file, []byte(id), 0o600); err != nil { + return id // ephemeral + } + return id +} + +// deviceIDDir returns the non-synced, per-user directory that holds the +// device-id file: $XDG_CACHE_HOME/moss when that env var is set, else +// /.moss. This matches the JS/Python/Elixir SDKs so one physical device +// resolves to a single id (R2.3). home = $HOME -> %USERPROFILE% -> OS home, +// with blank values skipped so a blank $HOME never resolves into the CWD (R2). +func deviceIDDir() (string, error) { + if xdg := strings.TrimSpace(os.Getenv("XDG_CACHE_HOME")); xdg != "" { + return filepath.Join(xdg, deviceIDCacheApp), nil + } + home, err := homeDir() + if err != nil { + return "", err + } + return filepath.Join(home, deviceIDDirName), nil +} + +// homeDir resolves the user home, skipping blank env values so a blank $HOME +// never resolves into the CWD (R2). +func homeDir() (string, error) { + if h := strings.TrimSpace(os.Getenv("HOME")); h != "" { + return h, nil + } + if h := strings.TrimSpace(os.Getenv("USERPROFILE")); h != "" { + return h, nil + } + return os.UserHomeDir() +} + +// newUUID returns a fresh random UUIDv4 as an opaque string, handed through +// unchanged (R1.2/R1.3, deviceId.ts:36, MossClient.swift:233). Implemented +// from crypto/rand (stdlib only) to avoid an external module dependency. +func newUUID() string { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + // crypto/rand should not fail; if it does the id would be all-zero, + // which is still a valid opaque string and never breaks the client. + return "00000000-0000-4000-8000-000000000000" + } + // Set the RFC 4122 version (4) and variant (10xx) bits. + b[6] = (b[6] & 0x0f) | 0x40 + b[8] = (b[8] & 0x3f) | 0x80 + + var buf [36]byte + hex.Encode(buf[0:8], b[0:4]) + buf[8] = '-' + hex.Encode(buf[9:13], b[4:6]) + buf[13] = '-' + hex.Encode(buf[14:18], b[6:8]) + buf[18] = '-' + hex.Encode(buf[19:23], b[8:10]) + buf[23] = '-' + hex.Encode(buf[24:36], b[10:16]) + return string(buf[:]) +} diff --git a/sdks/go/bindings/deviceid_test.go b/sdks/go/bindings/deviceid_test.go new file mode 100644 index 00000000..c30d499e --- /dev/null +++ b/sdks/go/bindings/deviceid_test.go @@ -0,0 +1,136 @@ +package mosscore + +import ( + "os" + "path/filepath" + "regexp" + "runtime" + "testing" +) + +var uuidRE = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`) + +// redirectConfigDir points device-id persistence at a temp dir and keeps the +// test hermetic. deviceIDDir uses $XDG_CACHE_HOME/moss when set, else +// /.moss, so we clear XDG_CACHE_HOME and set HOME/USERPROFILE at the temp +// dir; the file then lands at /.moss/.moss-device-id on every platform. +func redirectConfigDir(t *testing.T, dir string) { + t.Helper() + t.Setenv("XDG_CACHE_HOME", "") + t.Setenv("HOME", dir) + if runtime.GOOS == "windows" { + t.Setenv("USERPROFILE", dir) + } +} + +func TestNewUUIDFormat(t *testing.T) { + id := newUUID() + if !uuidRE.MatchString(id) { + t.Fatalf("newUUID() = %q, want a UUIDv4", id) + } + if newUUID() == id { + t.Fatalf("newUUID() returned the same value twice: %q", id) + } +} + +func TestTelemetryDisabledParsesTruthy(t *testing.T) { + cases := map[string]bool{ + "1": true, "true": true, "TRUE": true, " yes ": true, "on": true, + "0": false, "false": false, "": false, "off": false, + } + for v, want := range cases { + t.Setenv(disableTelemetryEnv, v) + if got := telemetryDisabled(); got != want { + t.Errorf("telemetryDisabled() with %q = %v, want %v", v, got, want) + } + } +} + +func TestResolveDeviceIDPersistsAndReuses(t *testing.T) { + dir := t.TempDir() + redirectConfigDir(t, dir) + t.Setenv(disableTelemetryEnv, "") + + first := resolveDeviceID() + if !uuidRE.MatchString(first) { + t.Fatalf("resolveDeviceID() = %q, want a UUIDv4", first) + } + // A second resolve must read back the same persisted id (cross-process + // stability; memoization is separate, tested below). + second := resolveDeviceID() + if second != first { + t.Fatalf("resolveDeviceID() not stable: %q then %q", first, second) + } + + base, err := deviceIDDir() + if err != nil { + t.Fatalf("deviceIDDir() error: %v", err) + } + file := filepath.Join(base, deviceIDFileName) + data, err := os.ReadFile(file) + if err != nil { + t.Fatalf("device-id file not written at %s: %v", file, err) + } + if string(data) != first { + t.Fatalf("persisted id = %q, want %q", string(data), first) + } + // Cross-SDK scheme: dir ".moss", file ".moss-device-id" (matches JS/Python/Elixir). + if filepath.Base(base) != ".moss" || filepath.Base(file) != ".moss-device-id" { + t.Fatalf("unexpected path %s", file) + } +} + +func TestResolveDeviceIDHonorsPreseededFile(t *testing.T) { + dir := t.TempDir() + redirectConfigDir(t, dir) + base, err := deviceIDDir() + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(base, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(base, deviceIDFileName), []byte("preseeded-id-1\n"), 0o600); err != nil { + t.Fatal(err) + } + if got := resolveDeviceID(); got != "preseeded-id-1" { + t.Fatalf("resolveDeviceID() = %q, want trimmed preseeded value", got) + } +} + +func TestResolveDeviceIDRegeneratesBlankFile(t *testing.T) { + dir := t.TempDir() + redirectConfigDir(t, dir) + base, err := deviceIDDir() + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(base, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(base, deviceIDFileName), []byte(" \n"), 0o600); err != nil { + t.Fatal(err) + } + if got := resolveDeviceID(); !uuidRE.MatchString(got) { + t.Fatalf("blank file should regenerate a UUID, got %q", got) + } +} + +func TestStableDeviceIDDisabled(t *testing.T) { + dir := t.TempDir() + redirectConfigDir(t, dir) + t.Setenv(disableTelemetryEnv, "1") + + id, ok := stableDeviceID() + if ok || id != "" { + t.Fatalf("stableDeviceID() with telemetry disabled = (%q, %v), want (\"\", false)", id, ok) + } + // No store I/O when disabled: the config dir must not exist. + base, err := deviceIDDir() + if err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(base, deviceIDFileName)); !os.IsNotExist(err) { + t.Fatalf("device-id file should not be written when telemetry disabled") + } +} diff --git a/sdks/go/bindings/libmoss.go b/sdks/go/bindings/libmoss.go index 4adbf8ed..e62a7e7b 100644 --- a/sdks/go/bindings/libmoss.go +++ b/sdks/go/bindings/libmoss.go @@ -9,6 +9,22 @@ package mosscore #include #include */ +// MOS-14 native-binding dependency: newCClient (below) calls +// moss_client_new_with_device_id, whose C prototype is: +// +// MossResult moss_client_new_with_device_id(const char *project_id, +// const char *project_key, +// const char *device_id, +// struct MossClient **out); +// +// (verified in moss-sdks-internal/bindings/c/libmoss.h:307-310). The mono repo +// does NOT vendor a libmoss.h — the header + libmoss.{so,dylib,dll} are +// supplied at build time via CGO_CFLAGS/CGO_LDFLAGS include paths. This wrapper +// therefore only compiles/links against a libmoss whose header declares +// moss_client_new_with_device_id, i.e. a header regenerated from a CI build of +// bindings/c that post-dates the device-id ABI. Against an older header this +// file fails at cgo compile time (undeclared function). BLOCKED on CI shipping +// that newer header/artifact; there is no in-repo header to gate on here. import "C" import ( @@ -439,8 +455,20 @@ func newCClient(projectID, projectKey string) (*C.MossClient, error) { cProjectKey := C.CString(projectKey) defer C.free(unsafe.Pointer(cProjectKey)) + // MOS-14 "better tracking": source a stable, persisted per-device id and + // hand it to the core through the device-id constructor (R5.1). When + // telemetry is disabled (MOSS_DISABLE_TELEMETRY), no id is sourced and we + // call the plain constructor instead, which emits no deviceId field + // (R4.1/R4.3). See deviceid.go. + deviceID, ok := stableDeviceID() + var out *C.MossClient if err := withErrorThread(func() C.MossResult { + if ok { + cDeviceID := C.CString(deviceID) + defer C.free(unsafe.Pointer(cDeviceID)) + return C.moss_client_new_with_device_id(cProjectID, cProjectKey, cDeviceID, &out) + } return C.moss_client_new(cProjectID, cProjectKey, &out) }); err != nil { return nil, err diff --git a/sdks/javascript/bindings/src/indexmanager.rs b/sdks/javascript/bindings/src/indexmanager.rs index ec7de087..83cb4adf 100644 --- a/sdks/javascript/bindings/src/indexmanager.rs +++ b/sdks/javascript/bindings/src/indexmanager.rs @@ -26,6 +26,15 @@ impl JsIndexManager { Ok(Self { inner, _runtime: runtime }) } + // TODO(MOS-14): add the device-id setter to reach parity with the TS SDK + // (which already sources the id and calls `setDeviceId`, degrading to a + // no-op until this exists). Add a `#[napi(js_name = "setDeviceId")] pub fn + // set_device_id(&self, device_id: Option)` that delegates to core + // `IndexManager::set_device_id` (ref moss-sdks-internal + // bindings/javascript/src/indexmanager.rs:57). Blocked on CI: this repo does + // not vendor the `moss` core crate, so the binding can't be built here and + // the prebuilt core must be a version that exposes the setter. + #[napi( js_name = "loadIndex", ts_args_type = "indexName: string, options?: LoadIndexOptions | null", diff --git a/sdks/javascript/sdk/CHANGELOG.md b/sdks/javascript/sdk/CHANGELOG.md index ffd5181b..2de1d0fe 100644 --- a/sdks/javascript/sdk/CHANGELOG.md +++ b/sdks/javascript/sdk/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [Unreleased] + +### Added + +- Device-id contract (MOS-14 "better tracking" parity). `loadIndex()` now sources a stable, persisted, per-device id and best-effort hands it to the core so per-device usage attribution is stable across restarts. + - UUIDv4 persisted to `.moss-device-id` under `cachePath` (when provided) else `/.moss` — non-synced/non-migrating, the same scheme as the Python/Go/Elixir SDKs so one physical device resolves to a single id across languages. + - Honors `MOSS_DISABLE_TELEMETRY` (truthy set `{1,true,yes,on}`, trimmed/lowercased), checked at runtime before the memo fast-path; memoized and applied once per client. + - Degrades to a no-op when the native binding does not yet expose `setDeviceId` (see the `TODO(MOS-14)` in `bindings/src/indexmanager.rs`); persistence failures fall back to an ephemeral id and never break `loadIndex()`. + - Contains no telemetry HTTP/buffer/flush code — the closed core owns transport. + ## [1.0.0] - 2026-04-01 ### Architecture diff --git a/sdks/javascript/sdk/src/client/internalMossClient.ts b/sdks/javascript/sdk/src/client/internalMossClient.ts index 2c3048c5..3b12f7e9 100644 --- a/sdks/javascript/sdk/src/client/internalMossClient.ts +++ b/sdks/javascript/sdk/src/client/internalMossClient.ts @@ -17,6 +17,7 @@ import type { import * as MossCore from "@moss-dev/moss-core"; import { CLOUD_API_MANAGE_URL, CLOUD_QUERY_URL } from "../constants"; import { CloudApiClient } from "../utils/cloudApiClient"; +import { applyDeviceIdOnce, type DeviceIdState } from "../utils/deviceId"; /** * Internal client — mutations go through Rust ManageClient, @@ -57,8 +58,22 @@ export class InternalMossClient { loadQueryModel(indexName: string): Promise; refreshIndex(indexName: string): Promise<{ indexName: string; previousUpdatedAt: string; newUpdatedAt: string; wasUpdated: boolean }>; getIndexInfo(indexName: string): Promise; + /** + * Present only on newer moss-core builds (napi `setDeviceId`). When absent + * (older `.node`), device-id plumbing degrades to a no-op — see + * `applyDeviceId`. MECHANISM for this SDK is the setter (R5.2). + */ + setDeviceId?(deviceId: string): void; }; + /** + * Shared per-device telemetry id state (MOS-14). Resolved once and memoized + * so the id is stable across resolves and applied to the core at most once. + * The SDK's only job is to source a stable id and hand it to the core; the + * closed core owns the /telemetry POST/buffer/flush. + */ + private readonly deviceIdState: DeviceIdState = { applied: false }; + constructor(projectId: string, projectKey: string) { this.cloudClient = new CloudApiClient(projectId, projectKey, CLOUD_API_MANAGE_URL, CLOUD_QUERY_URL); const runtime = MossCore as unknown as { @@ -199,6 +214,12 @@ export class InternalMossClient { indexName: string, options?: LoadIndexOptions, ): Promise { + // MOS-14: establish the stable per-device telemetry id (for Monthly Active + // Devices) once and hand it to the core via its setter (R5.2). Persist + // under this call's cachePath when given, else the per-user fallback dir. + // Best-effort — never blocks or fails the load (R2.4/R5.3). If the linked + // moss-core predates `setDeviceId`, this degrades to a no-op (R5.4). + applyDeviceIdOnce(this.indexManager, this.deviceIdState, options?.cachePath); const info = await this.indexManager.loadIndex(indexName, options ?? null); const modelId = info.model.id as MossModel; if (modelId !== "custom") { diff --git a/sdks/javascript/sdk/src/utils/deviceId.ts b/sdks/javascript/sdk/src/utils/deviceId.ts new file mode 100644 index 00000000..21da8235 --- /dev/null +++ b/sdks/javascript/sdk/src/utils/deviceId.ts @@ -0,0 +1,127 @@ +import { randomUUID } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join, resolve } from "node:path"; + +const DEVICE_ID_FILE = ".moss-device-id"; +const DEFAULT_DIR_NAME = ".moss"; +const TRUTHY = new Set(["1", "true", "yes", "on"]); + +/** True when usage telemetry is disabled via `MOSS_DISABLE_TELEMETRY`. */ +export function telemetryDisabled(env: NodeJS.ProcessEnv = process.env): boolean { + const v = env.MOSS_DISABLE_TELEMETRY; + return v != null && TRUTHY.has(v.trim().toLowerCase()); +} + +/** + * Resolve the stable per-device id persisted at `/.moss-device-id`. + * Reads an existing UUID, or generates and writes one. Returns `undefined` + * when telemetry is disabled. On a filesystem error, returns a fresh ephemeral + * UUID (not persisted) so telemetry can still attribute within this run — + * device-id persistence must never break `loadIndex`. + */ +export function resolveDeviceId( + cachePath: string, + env: NodeJS.ProcessEnv = process.env, +): string | undefined { + if (telemetryDisabled(env)) return undefined; + try { + const dir = resolve(cachePath); + const file = join(dir, DEVICE_ID_FILE); + if (existsSync(file)) { + const existing = readFileSync(file, "utf8").trim(); + if (existing) return existing; + } + mkdirSync(dir, { recursive: true }); + const id = randomUUID(); + writeFileSync(file, id, "utf8"); + return id; + } catch { + return randomUUID(); + } +} + +/** + * Per-user fallback directory for the device-id file, used when no `cachePath` + * is available — most notably the cloud-fallback query path, which has no + * cache directory of its own. Resolves to `/.moss`, where `home` comes + * from `$HOME` / `%USERPROFILE%` (falling back to `os.homedir()`). A single + * per-user location keeps a device's id stable across processes that never + * pass a `cachePath`, so it counts once toward Monthly Active Devices. + */ +export function defaultDeviceIdDir(env: NodeJS.ProcessEnv = process.env): string { + // `||` (not `??`) + trim so a blank `$HOME` / `%USERPROFILE%` falls through + // to `os.homedir()` rather than producing a relative `.moss` (which would + // land in the current working directory). + const home = env.HOME?.trim() || env.USERPROFILE?.trim() || homedir(); + return join(home, DEFAULT_DIR_NAME); +} + +export interface DeviceIdTarget { + // Optional: newer moss-core builds expose the napi `setDeviceId` setter; + // older `.node` builds do not (handled as terminal success in `applyDeviceId`). + setDeviceId?(deviceId: string): void; +} + +export interface DeviceIdState { + id?: string; + applied: boolean; +} + +/** + * Resolve the client's stable device id once and memoize it on `state`, so + * every telemetry surface a client touches reports the same id — one device, + * one id. Persists under `cachePath` when given, otherwise under the per-user + * fallback dir. Returns `undefined` (without memoizing) when telemetry is + * disabled. + */ +export function resolveClientDeviceId( + state: DeviceIdState, + cachePath: string | undefined, + env: NodeJS.ProcessEnv = process.env, +): string | undefined { + // Check disabled first, before the memoized fast-path, so toggling + // MOSS_DISABLE_TELEMETRY at runtime stops attribution immediately. + if (telemetryDisabled(env)) return undefined; + if (state.id) return state.id; + // Treat a blank cachePath as absent — `resolve("")` would point at the CWD. + const dir = cachePath?.trim() ? cachePath : defaultDeviceIdDir(env); + const id = resolveDeviceId(dir, env); + if (id) state.id = id; + return id; +} + +/** + * Push `id` to a telemetry `target`. Best-effort: never throws. Returns whether + * the id is now settled — `true` on success, or when the target's moss-core + * build predates `setDeviceId` (terminal: a newer binding won't appear + * mid-process, so there's nothing to retry). Returns `false` only when the + * call threw, so the caller may retry later. + */ +export function applyDeviceId(target: DeviceIdTarget, id: string): boolean { + if (typeof target.setDeviceId !== "function") return true; + try { + target.setDeviceId(id); + return true; + } catch { + return false; + } +} + +/** + * Resolve the device id (once, shared via `state`) and push it to `target`. + * No-op once applied or when telemetry is disabled. Used for the long-lived + * IndexManager. On a transient failure `state.applied` stays false so the next + * call retries rather than permanently suppressing the id. + */ +export function applyDeviceIdOnce( + target: DeviceIdTarget, + state: DeviceIdState, + cachePath: string | undefined, + env: NodeJS.ProcessEnv = process.env, +): void { + if (state.applied) return; + const id = resolveClientDeviceId(state, cachePath, env); + if (!id) return; + state.applied = applyDeviceId(target, id); +} diff --git a/sdks/javascript/sdk/test/deviceId.test.ts b/sdks/javascript/sdk/test/deviceId.test.ts new file mode 100644 index 00000000..bab02bb9 --- /dev/null +++ b/sdks/javascript/sdk/test/deviceId.test.ts @@ -0,0 +1,202 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, isAbsolute } from "node:path"; +import { + telemetryDisabled, + resolveDeviceId, + resolveClientDeviceId, + defaultDeviceIdDir, + applyDeviceId, + applyDeviceIdOnce, + type DeviceIdState, +} from "../src/utils/deviceId"; + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +describe("deviceId", () => { + let dir: string; + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "moss-devid-")); + }); + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + // --- resolveDeviceId --- + + it("creates and persists a UUID on first resolve", () => { + const id = resolveDeviceId(dir, {}); + expect(id).toMatch(UUID_RE); + const file = join(dir, ".moss-device-id"); + expect(existsSync(file)).toBe(true); + expect(readFileSync(file, "utf8").trim()).toBe(id); + }); + + it("returns the same id on subsequent resolves", () => { + const first = resolveDeviceId(dir, {}); + const second = resolveDeviceId(dir, {}); + expect(second).toBe(first); + }); + + it("honors a pre-seeded id file", () => { + writeFileSync(join(dir, ".moss-device-id"), "preseeded-id-1", "utf8"); + expect(resolveDeviceId(dir, {})).toBe("preseeded-id-1"); + }); + + it("returns undefined when telemetry is disabled", () => { + expect(resolveDeviceId(dir, { MOSS_DISABLE_TELEMETRY: "true" })).toBeUndefined(); + expect(existsSync(join(dir, ".moss-device-id"))).toBe(false); + }); + + it("telemetryDisabled parses common truthy values", () => { + expect(telemetryDisabled({ MOSS_DISABLE_TELEMETRY: "1" })).toBe(true); + expect(telemetryDisabled({ MOSS_DISABLE_TELEMETRY: "TRUE" })).toBe(true); + expect(telemetryDisabled({})).toBe(false); + expect(telemetryDisabled({ MOSS_DISABLE_TELEMETRY: "0" })).toBe(false); + }); + + // --- defaultDeviceIdDir --- + + it("defaultDeviceIdDir resolves to /.moss from $HOME", () => { + expect(defaultDeviceIdDir({ HOME: "/home/u" })).toBe(join("/home/u", ".moss")); + }); + + it("defaultDeviceIdDir falls back to %USERPROFILE% (Windows)", () => { + expect(defaultDeviceIdDir({ USERPROFILE: "C:\\Users\\u" })).toBe( + join("C:\\Users\\u", ".moss"), + ); + }); + + it("defaultDeviceIdDir ignores a blank $HOME (would otherwise resolve to CWD)", () => { + // A blank HOME must fall through, not produce a relative ".moss". + expect(defaultDeviceIdDir({ HOME: " ", USERPROFILE: "C:\\Users\\u" })).toBe( + join("C:\\Users\\u", ".moss"), + ); + expect(isAbsolute(defaultDeviceIdDir({ HOME: "" }))).toBe(true); + }); + + // --- resolveClientDeviceId --- + + it("resolveClientDeviceId persists under cachePath when provided", () => { + const state: DeviceIdState = { applied: false }; + const id = resolveClientDeviceId(state, dir, {}); + expect(id).toMatch(UUID_RE); + expect(readFileSync(join(dir, ".moss-device-id"), "utf8").trim()).toBe(id); + expect(state.id).toBe(id); + }); + + it("resolveClientDeviceId falls back to /.moss when no cachePath", () => { + const state: DeviceIdState = { applied: false }; + const id = resolveClientDeviceId(state, undefined, { HOME: dir }); + expect(id).toMatch(UUID_RE); + expect(readFileSync(join(dir, ".moss", ".moss-device-id"), "utf8").trim()).toBe(id); + }); + + it("resolveClientDeviceId memoizes the first id across calls and locations", () => { + const state: DeviceIdState = { applied: false }; + const first = resolveClientDeviceId(state, dir, {}); + // A later call with no cachePath must reuse the memoized id, not mint a + // second one from the fallback dir — one device, one id. + const second = resolveClientDeviceId(state, undefined, { HOME: join(dir, "other") }); + expect(second).toBe(first); + expect(existsSync(join(dir, "other", ".moss", ".moss-device-id"))).toBe(false); + }); + + it("resolveClientDeviceId returns undefined and does not memoize when telemetry disabled", () => { + const state: DeviceIdState = { applied: false }; + expect(resolveClientDeviceId(state, dir, { MOSS_DISABLE_TELEMETRY: "1" })).toBeUndefined(); + expect(state.id).toBeUndefined(); + }); + + it("resolveClientDeviceId honors a runtime telemetry-disable even after an id was memoized", () => { + const state: DeviceIdState = { id: "preset-id", applied: true }; + expect(resolveClientDeviceId(state, dir, { MOSS_DISABLE_TELEMETRY: "1" })).toBeUndefined(); + }); + + it("resolveClientDeviceId treats a blank cachePath as absent and uses the fallback dir", () => { + const state: DeviceIdState = { applied: false }; + const id = resolveClientDeviceId(state, " ", { HOME: dir }); + expect(id).toMatch(UUID_RE); + // Persisted to the fallback dir, not the CWD (`resolve(" ")`). + expect(readFileSync(join(dir, ".moss", ".moss-device-id"), "utf8").trim()).toBe(id); + }); + + // --- applyDeviceId --- + + it("applyDeviceId pushes the id to the target and reports success", () => { + const calls: string[] = []; + const ok = applyDeviceId({ setDeviceId: (d: string) => calls.push(d) }, "abc"); + expect(ok).toBe(true); + expect(calls).toEqual(["abc"]); + }); + + it("applyDeviceId treats a missing setDeviceId (older binding) as terminal success", () => { + const target = {} as unknown as { setDeviceId: (d: string) => void }; + expect(applyDeviceId(target, "abc")).toBe(true); + }); + + it("applyDeviceId reports failure when setDeviceId throws (so the caller can retry)", () => { + const target = { + setDeviceId: () => { + throw new Error("transient binding error"); + }, + }; + expect(applyDeviceId(target, "abc")).toBe(false); + }); + + // --- applyDeviceIdOnce --- + + it("applyDeviceIdOnce sets the id exactly once and memoizes", () => { + const calls: string[] = []; + const target = { setDeviceId: (d: string) => calls.push(d) }; + const state: DeviceIdState = { applied: false }; + + applyDeviceIdOnce(target, state, dir, {}); + applyDeviceIdOnce(target, state, dir, {}); + expect(calls.length).toBe(1); + expect(calls[0]).toMatch(UUID_RE); + expect(state.id).toBe(calls[0]); + }); + + it("applyDeviceIdOnce falls back to the default device-id dir when no cachePath", () => { + const calls: string[] = []; + const target = { setDeviceId: (d: string) => calls.push(d) }; + const state: DeviceIdState = { applied: false }; + applyDeviceIdOnce(target, state, undefined, { HOME: dir }); + expect(calls.length).toBe(1); + expect(calls[0]).toMatch(UUID_RE); + expect(state.applied).toBe(true); + expect(existsSync(join(dir, ".moss", ".moss-device-id"))).toBe(true); + }); + + it("applyDeviceIdOnce does nothing when telemetry disabled", () => { + const calls: string[] = []; + const target = { setDeviceId: (d: string) => calls.push(d) }; + const state: DeviceIdState = { applied: false }; + applyDeviceIdOnce(target, state, dir, { MOSS_DISABLE_TELEMETRY: "yes" }); + expect(calls.length).toBe(0); + }); + + it("applyDeviceIdOnce marks applied without throwing when setDeviceId is absent (older binding)", () => { + const target = {} as unknown as { setDeviceId: (d: string) => void }; + const state: DeviceIdState = { applied: false }; + expect(() => applyDeviceIdOnce(target, state, dir, {})).not.toThrow(); + expect(state.applied).toBe(true); + }); + + it("applyDeviceIdOnce leaves applied=false (retries) when setDeviceId throws", () => { + let calls = 0; + const target = { + setDeviceId: () => { + calls++; + throw new Error("transient binding error"); + }, + }; + const state: DeviceIdState = { applied: false }; + expect(() => applyDeviceIdOnce(target, state, dir, {})).not.toThrow(); + expect(state.applied).toBe(false); + applyDeviceIdOnce(target, state, dir, {}); // retried on next call + expect(calls).toBe(2); + }); +}); diff --git a/sdks/python/sdk/src/moss/client/device_id.py b/sdks/python/sdk/src/moss/client/device_id.py new file mode 100644 index 00000000..0750d4c6 --- /dev/null +++ b/sdks/python/sdk/src/moss/client/device_id.py @@ -0,0 +1,213 @@ +""" +Stable per-device id sourcing for usage telemetry (MOS-14). + +The closed Moss core owns the actual /telemetry POST + buffering + 3s flush. +This module's ONLY job is to source a stable, persisted, per-device id and hand +it to the core through the native binding's device-id entry point. It contains +zero telemetry HTTP / buffering / flush / event-composition code. + +Python is a "file platform": the fallback UUID is persisted in a plaintext file +named exactly ``.moss-device-id``. When the client has a cache directory the file +lives at ``/.moss-device-id``; otherwise it falls back to a single +per-user directory ``/.moss/.moss-device-id`` so the device resolves to the +same id across processes (one device, one id -> counts once toward Monthly +Active Devices). The store is per-user / device-local and is never synced. + +Modeled on the TypeScript reference +(moss-sdks-internal/javascript/user-facing-sdk/src/utils/deviceId.ts) and the +canonical MOS-14 device-id contract. + +NATIVE-BINDING STATUS (as of this change): the pyo3 binding ``moss_core`` does +NOT yet expose a ``set_device_id`` setter on ``IndexManager`` (nor a device-id +constructor). See ``apply_device_id`` / ``apply_device_id_once`` below: the apply +path degrades gracefully (terminal success) when the setter is absent, exactly +as the TS reference tolerates an older core. Full parity requires the Rust +change flagged in the module TODO. +""" + +from __future__ import annotations + +import os +import uuid +from pathlib import Path +from typing import Mapping, Optional, Protocol + +# --------------------------------------------------------------------------- +# TODO(MOS-14, native/Rust — needs CI to build the wheel): +# Add ``set_device_id(&self, device_id: Option)`` to ``PyIndexManager`` +# in moss/sdks/python/bindings/src/indexmanager.rs, delegating to core +# ``IndexManager::set_device_id`` (verified to exist in moss-sdks-internal: +# src/manager/indexmanager.rs:256 -> src/telemetry.rs:167). Until that method +# exists on the built ``moss_core.IndexManager``, ``apply_device_id`` treats +# the missing setter as terminal success and the id is sourced/persisted but +# not yet handed to the core. +# --------------------------------------------------------------------------- + +DEVICE_ID_FILE = ".moss-device-id" +DEFAULT_DIR_NAME = ".moss" + +# Persistence "identity" intent, mirroring the Apple Keychain reference +# (service="dev.moss.sdk", account="device_id"). On file platforms these are +# not separately addressable; the fixed filename ``.moss-device-id`` under the +# ``.moss`` per-user dir plays the same role. +KEYCHAIN_SERVICE_INTENT = "dev.moss.sdk" +KEYCHAIN_ACCOUNT_INTENT = "device_id" + +_TRUTHY = frozenset({"1", "true", "yes", "on"}) + + +def telemetry_disabled(env: Optional[Mapping[str, str]] = None) -> bool: + """True when usage telemetry is disabled via ``MOSS_DISABLE_TELEMETRY``. + + Truthy set = {"1","true","yes","on"}, trimmed + lowercased. + """ + environ = os.environ if env is None else env + value = environ.get("MOSS_DISABLE_TELEMETRY") + if value is None: + return False + return value.strip().lower() in _TRUTHY + + +def default_device_id_dir(env: Optional[Mapping[str, str]] = None) -> Path: + """Per-user fallback directory for the device-id file (``/.moss``). + + Used when no cache directory is available. ``home`` comes from ``$HOME`` -> + ``%USERPROFILE%`` -> OS home, with blank values skipped so a blank ``$HOME`` + does not resolve the ``.moss`` dir into the current working directory. A + single per-user location keeps a device's id stable across processes. + """ + environ = os.environ if env is None else env + + def _clean(name: str) -> Optional[str]: + raw = environ.get(name) + if raw is None: + return None + trimmed = raw.strip() + return trimmed or None + + home = _clean("HOME") or _clean("USERPROFILE") + if home is None: + # os.path.expanduser("~") consults the real OS home / password db and + # does not depend on the (possibly-cleared) env mapping passed in. + home = os.path.expanduser("~") + return Path(home) / DEFAULT_DIR_NAME + + +def resolve_device_id( + cache_path: str | os.PathLike[str], + env: Optional[Mapping[str, str]] = None, +) -> Optional[str]: + """Resolve the stable per-device id persisted at ``/.moss-device-id``. + + Reads an existing UUID, or generates and writes one. Returns ``None`` when + telemetry is disabled. On a filesystem error, returns a fresh *ephemeral* + UUID (not persisted) so telemetry can still attribute within this run — + device-id persistence must never break the client (loadIndex/query). + """ + if telemetry_disabled(env): + return None + try: + directory = Path(cache_path).expanduser().resolve() + file = directory / DEVICE_ID_FILE + if file.exists(): + existing = file.read_text(encoding="utf-8").strip() + if existing: # Never send an empty/blank id; regenerate if blank. + return existing + directory.mkdir(parents=True, exist_ok=True) + new_id = str(uuid.uuid4()) + file.write_text(new_id, encoding="utf-8") + return new_id + except OSError: + # Persistence failure must never break the client — fall back to a fresh + # ephemeral (non-persisted) UUID and continue. + return str(uuid.uuid4()) + + +class DeviceIdState: + """Per-client memo state so every telemetry surface reports the same id. + + ``id`` is the resolved device id (once resolved); ``applied`` tracks whether + the id has been pushed to the core successfully so we don't re-set it. + """ + + __slots__ = ("id", "applied") + + def __init__(self) -> None: + self.id: Optional[str] = None + self.applied: bool = False + + +def resolve_client_device_id( + state: DeviceIdState, + cache_path: Optional[str] = None, + env: Optional[Mapping[str, str]] = None, +) -> Optional[str]: + """Resolve the client's stable device id once and memoize it on ``state``. + + Every surface a client touches reports the same id — one device, one id. + Persists under ``cache_path`` when given, otherwise under the per-user + fallback dir. Returns ``None`` (without memoizing) when telemetry is + disabled. The disabled check runs *before* the memo fast-path so a runtime + opt-out takes effect immediately. + """ + if telemetry_disabled(env): + return None + if state.id: + return state.id + # Treat a blank cache_path as absent (an empty path would resolve to CWD). + directory: str | os.PathLike[str] + if cache_path is not None and cache_path.strip(): + directory = cache_path + else: + directory = default_device_id_dir(env) + resolved = resolve_device_id(directory, env) + if resolved: + state.id = resolved + return resolved + + +class DeviceIdTarget(Protocol): + """A core binding surface that accepts a device id via ``set_device_id``.""" + + def set_device_id(self, device_id: str) -> None: ... + + +def apply_device_id(target: object, device_id: str) -> bool: + """Push ``device_id`` to a telemetry ``target``. Best-effort: never raises. + + Returns whether the id is now settled: ``True`` on success, or when the + target's ``moss_core`` build predates ``set_device_id`` (terminal — a newer + binding won't appear mid-process, so there's nothing to retry). Returns + ``False`` only when the call raised, so the caller may retry later. + """ + setter = getattr(target, "set_device_id", None) + if not callable(setter): + # Older core binding without the device-id entry point — treat as + # terminal success (R5.4). NOTE: the current mono pyo3 binding is in + # this state until the native TODO above lands. + return True + try: + setter(device_id) + return True + except Exception: + return False + + +def apply_device_id_once( + target: object, + state: DeviceIdState, + cache_path: Optional[str] = None, + env: Optional[Mapping[str, str]] = None, +) -> None: + """Resolve the device id (once, shared via ``state``) and push it to ``target``. + + No-op once applied or when telemetry is disabled. On a transient failure + ``state.applied`` stays ``False`` so the next call retries rather than + permanently suppressing the id. Never raises. + """ + if state.applied: + return + device_id = resolve_client_device_id(state, cache_path, env) + if not device_id: + return + state.applied = apply_device_id(target, device_id) diff --git a/sdks/python/sdk/src/moss/client/moss_client.py b/sdks/python/sdk/src/moss/client/moss_client.py index 51bd2833..860df509 100644 --- a/sdks/python/sdk/src/moss/client/moss_client.py +++ b/sdks/python/sdk/src/moss/client/moss_client.py @@ -22,6 +22,8 @@ SearchResult, ) +from .device_id import DeviceIdState, apply_device_id_once + logger = logging.getLogger(__name__) @@ -74,6 +76,21 @@ def __init__(self, project_id: str, project_key: str) -> None: project_id, project_key, manage_url, self._client_id ) + # MOS-14: source a stable, persisted, per-device id and hand it to the + # core through the binding's device-id entry point. This is the SDK's + # only telemetry job — the closed core owns the /telemetry POST + buffer + # + 3s flush. Best-effort and never raises: a persistence or apply error + # must never break construction (or any later operation). Python has no + # cache dir here, so the id persists under the per-user ~/.moss fallback, + # keeping one device -> one id across processes. + # + # NOTE: the current pyo3 binding does not yet expose set_device_id, so + # apply_device_id_once resolves+persists the id but the apply is a + # graceful no-op (terminal success) until the native change lands. See + # device_id.py's module TODO for the exact Rust change required. + self._device_id_state = DeviceIdState() + apply_device_id_once(self._manager, self._device_id_state) + # -- Mutations (via Rust ManageClient) -------------------------- async def create_index( diff --git a/sdks/python/sdk/tests/test_device_id.py b/sdks/python/sdk/tests/test_device_id.py new file mode 100644 index 00000000..799b887f --- /dev/null +++ b/sdks/python/sdk/tests/test_device_id.py @@ -0,0 +1,199 @@ +"""Unit tests for the MOS-14 device-id util (moss.client.device_id). + +Modeled on the TypeScript reference test +(moss-sdks-internal/javascript/user-facing-sdk/test/deviceId.test.ts). These +tests are self-contained (stdlib + a fake set_device_id target) and do not +require the native moss_core binding. +""" + +from __future__ import annotations + +import uuid + +import pytest + +from moss.client.device_id import ( + DEVICE_ID_FILE, + DeviceIdState, + apply_device_id, + apply_device_id_once, + default_device_id_dir, + resolve_client_device_id, + resolve_device_id, + telemetry_disabled, +) + + +def _is_uuid(value: str) -> bool: + try: + uuid.UUID(value) + return True + except (ValueError, TypeError): + return False + + +# -- telemetry_disabled ------------------------------------------------ + + +class TestTelemetryDisabled: + @pytest.mark.parametrize("val", ["1", "true", "TRUE", " Yes ", "on"]) + def test_truthy_values_disable(self, val): + assert telemetry_disabled({"MOSS_DISABLE_TELEMETRY": val}) is True + + @pytest.mark.parametrize("val", ["", "0", "false", "no", "off", "nope"]) + def test_falsy_values_enabled(self, val): + assert telemetry_disabled({"MOSS_DISABLE_TELEMETRY": val}) is False + + def test_absent_is_enabled(self): + assert telemetry_disabled({}) is False + + +# -- resolve_device_id ------------------------------------------------- + + +class TestResolveDeviceId: + def test_generates_and_persists_uuid(self, tmp_path): + got = resolve_device_id(tmp_path, env={}) + assert got is not None and _is_uuid(got) + assert (tmp_path / DEVICE_ID_FILE).read_text(encoding="utf-8") == got + + def test_stable_across_resolves(self, tmp_path): + first = resolve_device_id(tmp_path, env={}) + second = resolve_device_id(tmp_path, env={}) + assert first == second + + def test_reads_existing_file(self, tmp_path): + (tmp_path / DEVICE_ID_FILE).write_text("preexisting-id", encoding="utf-8") + assert resolve_device_id(tmp_path, env={}) == "preexisting-id" + + def test_blank_existing_is_regenerated(self, tmp_path): + (tmp_path / DEVICE_ID_FILE).write_text(" \n", encoding="utf-8") + got = resolve_device_id(tmp_path, env={}) + assert got is not None and _is_uuid(got) + + def test_disabled_returns_none_and_writes_nothing(self, tmp_path): + got = resolve_device_id(tmp_path, env={"MOSS_DISABLE_TELEMETRY": "1"}) + assert got is None + assert not (tmp_path / DEVICE_ID_FILE).exists() + + +# -- default_device_id_dir --------------------------------------------- + + +class TestDefaultDeviceIdDir: + def test_uses_home(self): + d = default_device_id_dir({"HOME": "/home/alice"}) + assert str(d) == "/home/alice/.moss" + + def test_blank_home_falls_through(self, monkeypatch): + # A blank HOME must not resolve `.moss` into the CWD; it falls through + # to the real OS home via expanduser. + d = default_device_id_dir({"HOME": " "}) + assert d.name == ".moss" + assert d.is_absolute() + + def test_userprofile_fallback(self): + d = default_device_id_dir({"USERPROFILE": "/Users/bob"}) + assert str(d) == "/Users/bob/.moss" + + +# -- resolve_client_device_id (memoization) ---------------------------- + + +class TestResolveClientDeviceId: + def test_memoizes_on_state(self, tmp_path): + state = DeviceIdState() + first = resolve_client_device_id(state, str(tmp_path), env={}) + assert first == state.id + # Corrupt the file; memoized value must be returned without re-reading. + (tmp_path / DEVICE_ID_FILE).write_text("changed", encoding="utf-8") + second = resolve_client_device_id(state, str(tmp_path), env={}) + assert second == first + + def test_disabled_checked_before_memo(self, tmp_path): + state = DeviceIdState() + state.id = "memoized" + got = resolve_client_device_id( + state, str(tmp_path), env={"MOSS_DISABLE_TELEMETRY": "1"} + ) + assert got is None + + def test_blank_cache_path_uses_default_dir(self, tmp_path): + state = DeviceIdState() + got = resolve_client_device_id(state, " ", env={"HOME": str(tmp_path)}) + assert got is not None + assert (tmp_path / ".moss" / DEVICE_ID_FILE).exists() + + +# -- apply_device_id --------------------------------------------------- + + +class _FakeTarget: + def __init__(self): + self.calls = [] + + def set_device_id(self, device_id): + self.calls.append(device_id) + + +class _ThrowingTarget: + def set_device_id(self, device_id): + raise RuntimeError("boom") + + +class _NoSetterTarget: + pass + + +class TestApplyDeviceId: + def test_calls_setter(self): + t = _FakeTarget() + assert apply_device_id(t, "abc") is True + assert t.calls == ["abc"] + + def test_missing_setter_is_terminal_success(self): + assert apply_device_id(_NoSetterTarget(), "abc") is True + + def test_throwing_setter_returns_false(self): + assert apply_device_id(_ThrowingTarget(), "abc") is False + + +# -- apply_device_id_once ---------------------------------------------- + + +class TestApplyDeviceIdOnce: + def test_applies_and_marks(self, tmp_path): + t = _FakeTarget() + state = DeviceIdState() + apply_device_id_once(t, state, str(tmp_path), env={}) + assert state.applied is True + assert len(t.calls) == 1 + + def test_no_op_once_applied(self, tmp_path): + t = _FakeTarget() + state = DeviceIdState() + apply_device_id_once(t, state, str(tmp_path), env={}) + apply_device_id_once(t, state, str(tmp_path), env={}) + assert len(t.calls) == 1 + + def test_disabled_does_not_apply(self, tmp_path): + t = _FakeTarget() + state = DeviceIdState() + apply_device_id_once( + t, state, str(tmp_path), env={"MOSS_DISABLE_TELEMETRY": "1"} + ) + assert state.applied is False + assert t.calls == [] + assert not (tmp_path / DEVICE_ID_FILE).exists() + + def test_failed_apply_leaves_retryable(self, tmp_path): + t = _ThrowingTarget() + state = DeviceIdState() + apply_device_id_once(t, state, str(tmp_path), env={}) + assert state.applied is False # retryable + + def test_missing_setter_binding_is_terminal_success(self, tmp_path): + t = _NoSetterTarget() + state = DeviceIdState() + apply_device_id_once(t, state, str(tmp_path), env={}) + assert state.applied is True # nothing to retry