This document describes how to implement scripts in this repo that modify a
Unity Analytics environment (events / parameters / schemas) via the
live-ops/events/v3 API. It is derived from the patterns already established
in methods.py, main_copy.py, main_copy_single.py, main_compare.py,
main_add_param_to_all_schemas.py, main_create_custom_echoes_of_standard_schemas.py
and main_recompile.py.
The single most important property these scripts must have is the operator can be confident that the environment about to be mutated is the one they intended. Everything in this design exists to enforce that property.
A script in this repo can:
- Create new events and parameters in an environment.
- Modify existing event schemas (add parameters, change descriptions, toggle enabled).
- Change the type of a parameter (admin-only endpoint —
change_parameter_type). - Copy schemas between environments.
Every one of those actions can silently corrupt a production environment if
the wrong trgtOrgId / trgtProjectId / trgtEnvId is sitting in
settings.json. The IDs are opaque GUIDs — visually indistinguishable. A
mis-typed character in trgtEnvId does not produce an error: it produces
"successful" writes against the wrong place.
The mitigation strategy below is therefore defence in depth: settings are loaded → the API is queried for the human-readable names that those IDs resolve to → the operator must confirm both the names and the deep-link URLs before any write is issued.
Every script uses the existing init_session() helper. New scripts MUST do
the same — do not roll your own loader.
from methods import init_session
rh, settings, src_project, target_project = init_session(
validate_src=True, # set False for write-to-source-only scripts
validate_target=True, # set False for read-only-of-source scripts
)Conventions:
-
settings.jsonstores a dictionary of named locations plus an active pointer selecting the current source/target:{ "locations": { "auth_linking_and_cloud_save": { "orgId": 18966763638557, "projectId": "714657ca-...", "envId": "4df3ce35-..." }, "ilyon - jungle jam - dev": { "orgId": ..., "projectId": ..., "envId": ... } }, "active": { "source": "auth_linking_and_cloud_save", "target": "ilyon - jungle jam - dev" } }The friendly name is the dict key and is assumed unique.
active.source/active.targeteach name a location (ornull). An MCP tool flips the active pair by callingset_active(settings, role, friendly_name, path). -
Backwards-compatible flat keys. Scripts still read
settings["srcOrgId"]…settings["trgtEnvId"].init_sessionresolves the active source/target locations and injects those six legacy flat keys into the returnedsettingsdict, so the skeleton in §4 (and every existingmain_*.py) is unchanged. Single-environment scripts still passvalidate_target=Falseand operate against thesrc*triple, which now derives fromactive.source. -
Legacy migration. An old flat-shaped
settings.json(top-levelsrcOrgId/trgt*+ optionalsuspendedblock) is auto-converted to the locations/active shape on load viamigrate_settings_if_legacyand rewritten to disk once. No manual edit needed. -
token.authholds a single Bearer token copied from thetokencookie at https://cloud.unity.com/. Both files are gitignored and per-operator. -
Any script that mutates the target environment must call
init_sessionwithvalidate_target=Trueso a 200 fromget_project_metadatais recorded before the first write._fetch_project_or_refresh_tokenwill prompt for a fresh token once and retry; if that fails the script exits.
get_project_metadata is the cheapest authoritative round-trip that proves:
- The token is valid.
- The org/project pair actually exists.
- The operator has access to it.
Without this, a typo'd project ID fails late — possibly after a partial write — instead of failing at startup. New scripts must never skip this step.
Every script that writes opens with the same pattern. Do not deviate:
<TITLE LINE — what the script will do, in one sentence>
-------------------------------------------------
Here's a link to the SOURCE project <name>:
https://cloud.unity.com/home/organizations/<org>/projects/<proj>/environments/<env>
[press enter to continue]
-------------------------------------------------
Here's a link to the TARGET project <name>:
https://cloud.unity.com/home/organizations/<org>/projects/<proj>/environments/<env>
[press enter to continue — THERE WILL BE NO FURTHER CONFIRMATIONS]
Rules:
- Print the project's display name (
src_project["name"]) alongside the IDs.init_sessionreturns the metadata for exactly this reason — use it. IDs alone are not human-verifiable; names are. - Print the deep-link URL to
cloud.unity.com. The operator must be able to click it, see the environment in the dashboard, and visually confirm the project name, environment name, and event/parameter list match what they expect. URL-only or name-only is not enough — together they are. - Use
input()to gate progression. Do not auto-proceed. The text "If this is correct, press enter to continue... If not, terminate the script and change the settings.json file." is the established phrasing — keep it. - The final confirmation must say "THERE WILL BE NO FURTHER CONFIRMATIONS". This signals to the operator that this is the last point at which they can abort cheaply. Per-record confirmations during the run are explicitly not the model — they cause confirmation fatigue and get click-through'd.
- If the script also requires a within-environment selection (e.g.
"which event do you want to copy?", "which parameter to add?"), that
selection prompt comes after the env confirmations and before the
"no further confirmations" line. See
main_copy_single.pyandmain_add_param_to_all_schemas.pyfor the canonical shape.
For clarity, scripts should label environments by their role in this script, not by the settings key:
- A copy script reads from SOURCE and writes to TARGET — both blocks shown.
- A recompile script writes to SOURCE only — only the source block shown,
but the title makes the write-intent explicit (
main_recompile.pydoes this correctly:" RECOMPILE EVENT SCHEMAS TO MATCH THE UI"). - An "echoes" script writes to SOURCE but the operator's mental model is
that it's the target of the operation —
main_create_custom_echoes_of_standard_schemas.pyhandles this by labelling the source-env block with "Here's a link to the TARGET project". This is fine if the title clearly says what's happening; ambiguity here is the most dangerous failure mode.
from methods import init_session
from methods import recursively_get_parameters, convert_event_from_get_to_post
from methods import check_for_prefix
# 1. Load + validate config (this also validates the token).
rh, settings, src_project, target_project = init_session(
validate_src=True, validate_target=True,
)
org_id = settings["srcOrgId"]
project_id = settings["srcProjectId"]
environment_id = settings["srcEnvId"]
target_org_id = settings["trgtOrgId"]
target_project_id = settings["trgtProjectId"]
target_env_id = settings["trgtEnvId"]
project_name = src_project.get("name", "Unknown Project")
target_project_name = target_project.get("name", "Unknown Project")
# 2. Title + confirmation block (see section 3).
print(" <WHAT THIS SCRIPT DOES> ")
print("-------------------------------------------------")
print(f"Here's a link to the SOURCE project {project_name}:")
print(f"https://cloud.unity.com/home/organizations/{org_id}/projects/{project_id}/environments/{environment_id}")
input("If this is correct, press enter to continue... If not, terminate the script and change the settings.json file.")
print("-------------------------------------------------")
print(f"Here's a link to the TARGET project {target_project_name}:")
print(f"https://cloud.unity.com/home/organizations/{target_org_id}/projects/{target_project_id}/environments/{target_env_id}")
input("If this is correct, press enter to continue... If not, terminate the script and change the settings.json file. \n THERE WILL BE NO FURTHER CONFIRMATIONS.")
# 3. (optional) Within-env selection — print options, take input, confirm.
# 4. Fetch — get current state of source and target.
src_schemas = rh.get_schemas(org_id, project_id, environment_id)
if not src_schemas:
# Treat empty as "token expired". This is the established failure-mode
# tell for these scripts. See main_copy.py for the standard message.
print("No schemas found in source project. ...")
exit(1)
# 5. Plan — figure out what would change. Idempotency check goes here.
# 6. Mutate — write each change, log per-record success/failure, do not abort
# on a single failure unless the failure could leave the env corrupt.Before any write, the script must:
- Fetch the target environment's current state (events, parameters).
- Compute the diff: what would be created, updated, or skipped.
- Skip anything that already exists at the target (idempotency).
Examples in the codebase:
main_copy.py: skips parameters whose name is already indestination_params, and events whose name is already indestination_events.main_create_custom_echoes_of_standard_schemas.py: skips standard events whose_customecho already exists.main_add_param_to_all_schemas.py: compares the parameter dict before/after insertion and onlyPUTs the schema iflen(new) > len(old).
Idempotency means re-running the script after a partial failure is safe. Without it, a flaky network or expired token mid-run leaves the operator unable to resume.
The Unity Analytics environment contains records that are owned by Unity, not the operator. New scripts must respect them.
isRestricted: true/isPredefined: trueevents — skip in copy scripts (they cannot be POSTed to a target). For modify-source scripts likemain_recompile.py, also skip — the API will reject the patch.- Reserved-prefix names —
check_for_prefix(name)returnsFalsefor names starting withrsv,ddna,deltaDNA, orunity. These cannot be created in a target environment. Copy/create scripts must call this and skip with a clear log line. - Type
OBJECT/ARRAYparameters — cannot be added as leaf children of an event'seventParamsblock;main_add_param_to_all_schemas.pyfilters these out of the operator's selection list. Any script that lets the operator pick a parameter must do the same.
- Treat an empty list response from
get_schemas/get_parametersas "probably an expired token" and exit with the established message that points the operator at thetokencookie. Do not retry silently. - The request helper in
methods.pyalready logs non-200 responses and returns{}. Per-record write failures should beprint()ed with the record name and the script should continue to the next record (unless the failure indicates a systemic problem like an auth fault). - Do not catch exceptions broadly. If an unexpected shape comes back from the API the script should crash loudly — silent corruption is worse than a stack trace.
When implementing a new modify-script that needs an endpoint not yet in
AnalyticsUIRequestHelper:
- Find the route in the
live-ops/events/v3spec. - Add a method that takes
org_id, project_id, environment_id, ...as the first three arguments — matching every other method in the class. - Use the appropriate
_request_*_and_validatehelper (these centralise the logging and JSON handling — do not callrequestsdirectly). - Build the URL as a relative path starting with
/api/...so the helper prependsself.base_url. (A few existing methods build absolute URLs inline — that's a wart, not a pattern to copy.) - Validate inputs that the API will reject (see
create_parameter's type whitelist andchange_parameter_type's type whitelist) so the script fails fast rather than after a round-trip.
Before merging a new main_*.py:
- Calls
init_sessionwith the correctvalidate_src/validate_targetflags for the side(s) it will write to. - Title line clearly states the write intent (verb + object).
- Prints SOURCE and/or TARGET confirmation blocks with name + URL +
input()gate, in the order the operator will think about them. - Final gate ends with
THERE WILL BE NO FURTHER CONFIRMATIONS. - Fetches target state and computes a diff before writing.
- Skips restricted / predefined / reserved-prefix records with a logged reason.
- Re-running the script after a partial failure is safe (idempotent).
- On per-record failure, logs the record name and continues; on systemic failure, exits with a message pointing at the likely cause (token, settings, network).
- No new direct
requests.*calls — all HTTP goes throughAnalyticsUIRequestHelper.
Sourced from routes.yaml. Two route shapes exist for every operation —
the org-scoped form (/organizations/{organizationId}/projects/...) and a
"NoOrg" form (/projects/...) that infers the org from auth. Stick with
the org-scoped form in this codebase: settings.json always carries
srcOrgId / trgtOrgId, the URL printed in confirmation blocks contains the
org segment (so the operator's visual check matches the request that's about
to fire), and methods.py already uses it consistently. Mixing forms across
scripts is a footgun — different URLs for the same logical action makes
audit/log review harder.
| Operation | Method + path (org-scoped) | Wrapped in methods.py? |
|---|---|---|
| List schemas | GET /schemas |
get_schemas |
| Create schema | POST /schemas |
create_event |
| Get schema | GET /schemas/{eventName} |
get_schema_by_id |
| Patch schema (desc + enabled) | PATCH /schemas/{eventName} |
patch_event |
| Replace schema | PUT /schemas/{eventName} |
update_schema |
| Copy schemas → other env(s) | POST /schemas/copy |
copy_schemas_in_project |
| List parameters | GET /parameters |
get_parameters (also list_parameters_in_environment) |
| Create parameter | POST /parameters |
create_parameter |
| Get parameter | GET /parameters/{parameterName} |
get_parameter_by_id |
| Update parameter | PATCH /parameters/{parameterName} |
No |
| Delete parameter | DELETE /parameters/{parameterName} |
No |
| Change parameter type (admin) | PATCH /parameters/{parameterName}/admin/type/{newType} |
change_parameter_type |
| Bulk add parameters to events | POST /bulk/add-parameters-to-events |
No |
PATCH /parameters/{parameterName} — wrap when first needed. Same
shape as create_parameter minus the name field. Standard rules apply
(reserved-prefix check, type validation).
POST /bulk/add-parameters-to-events — main_add_param_to_all_schemas.py
currently does this one event at a time via repeated PUT /schemas/{name}.
That's N round-trips, no transactionality, and partial-failure recovery is
the operator's problem. A bulk wrapper would replace that loop with one call.
When wrapping, keep the per-event diff/skip logic the script already does
(only send the events that actually need the parameter added) — do not blindly
forward the operator's selection to the bulk endpoint.
DELETE /parameters/{parameterName} — see section 10. Do not add a
wrapper without also adding the destructive-operation guards described there.
The standard confirmation flow in section 3 is calibrated for additive operations: "I'm about to create things in this environment." Two endpoints are not additive and require stronger guards:
DELETE /parameters/{parameterName}— irreversible. The parameter is gone, and any historical events that referenced it lose their schema binding.PATCH /parameters/{parameterName}/admin/type/{newType}— reversible only by another type-change call, and only if you know the original type. Type changes can break downstream queries and dashboards silently.
Rules for any script that calls one of these:
- Operate on TARGET, never on a SOURCE-only flow. Destructive scripts
must use
validate_target=Trueand write only against thetrgt*IDs, regardless of whatsrc*is set to. This makes "I'm about to delete from the wrong environment" much harder — the operator has to have target pointed at the wrong place, not just source. - Print a per-record manifest and require a typed confirmation. Unlike
additive scripts, "press enter" is not enough. After the env confirmation
blocks, print the full list of records that would be affected, then
require the operator to type the environment name (not the GUID, the
human-readable name from
target_project["name"]orget_project_metadata's env list) verbatim before proceeding. Mismatched input → exit, no retries. - Refuse to act on restricted/predefined records. Belt and braces: the API will reject these, but the script should refuse before sending.
- Dry-run mode by default. A
--apply(or equivalent) flag must be passed for the script to actually issue the destructive calls. The default invocation prints what would happen and exits. - Log every destructive call with its full URL and the API's response status. This is the audit trail.
These rules apply equally to any future endpoint that mutates existing data
in place (e.g. a future DELETE /schemas/{eventName} should it appear).
Treat the Admin Operations tag in the spec as a flag: anything tagged
admin gets the section-10 treatment, not the section-3 treatment.