Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion apps/admin-db/pg-init.sql
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,16 @@ CREATE TABLE app_links (
);


CREATE TABLE oa_uid_issuances (
id SERIAL PRIMARY KEY,
event_uid UUID UNIQUE NOT NULL,
oa_uid UUID NOT NULL,
store_id VARCHAR(100) NOT NULL,
ifa UUID NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);


INSERT INTO networks (name, postback_id, status, is_custom) VALUES
('Google','google', 'inactive', FALSE),
('Meta', 'meta', 'inactive', FALSE),
Expand All @@ -70,4 +80,3 @@ INSERT INTO networks (name, postback_id, status, is_custom) VALUES
('Social Media Posts', 'customsocial', 'inactive', TRUE)
;


13 changes: 11 additions & 2 deletions apps/docs/docs/docs/getting_started/tracking_links.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ ifa: The IFA (Identifier for Advertisers) of the user.
event_id: The event ID from the attribution platform.
event_time: The timestamp of the event in milliseconds since epoch.
link_uid: The unique identifier for the link.
oa_uid: The unique identifier for the user.
oa_uid: The unique identifier for the user. This can be omitted for the first
app_open so the server can create and return one.

### In App IDs
event_uid: The unique identifier for the event. This is a randomly generated UUID to deduplicate events.
Expand All @@ -39,6 +40,14 @@ Content-Type: application/json
"ifa": "00000000-0000-0000-0000-000000000000",
"event_time": 1732003510046,
"event_uid": "5730a99e-b009-41da-9d52-1315e26941c1",
"event_id": "app_open",
"event_id": "app_open"
}
```

Sample response for the first app_open:

```json
{
"oa_uid": "3bd9e091-fa6e-4b91-8dd1-503f8d4fe8f2"
}
```
11 changes: 9 additions & 2 deletions apps/docs/docs/docs/todo/sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,14 @@ Content-Type: application/json
"ifa": "00000000-0000-0000-0000-000000000000",
"event_time": 1732003510046,
"event_uid": "5730a99e-b009-41da-9d52-1315e26941c1",
"event_id": "app_open",
"event_id": "app_open"
}
```

The server response can then return an `oa_uid` for the SDK to store and reuse:

```json
{
"oa_uid": "3bd9e091-fa6e-4b91-8dd1-503f8d4fe8f2"
}
```
Expand Down Expand Up @@ -74,4 +81,4 @@ Content-Type: application/json
- Apple AdAttributionKit support as a separate target. Apps prefer server control over conversion values and mappings.
- Token Collection:
- Apple attribution token for Apple Search Ads.
- Link query parameters for other ad networks, mainly for web-to-app transitions.
- Link query parameters for other ad networks, mainly for web-to-app transitions.
36 changes: 29 additions & 7 deletions apps/postback-api/api_app/controllers/postbacks.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,15 @@
from litestar.params import Parameter

from api_app.models import ClickData, EventData, ImpressionData, RequestEventData
from api_app.oa_uid import issue_oa_uid_for_first_open
from api_app.sendkafka import to_kafka
from api_app.tools import EMPTY_IFA, get_client_ip, is_valid_ifa, is_valid_uuid, now
from api_app.tools import (
EMPTY_IFA,
get_client_ip,
is_valid_ifa,
is_valid_uuid,
now,
)

logger = get_logger(__name__)

Expand Down Expand Up @@ -293,7 +300,7 @@ async def events(
request: Request,
app: str,
data: RequestEventData,
) -> None:
) -> dict[str, str]:
"""
Record event postbacks from in app.

Expand All @@ -312,13 +319,15 @@ async def events(
event_uid (str): A unique generated UID for the event, this is used for deduplication.
ifa (str, optional): Identifier for Advertisers.
revenue (str, optional): The numerical value of the revenue in USD. Examples: '1', '1.00', '0.222'
oa_uid (str): The unique ID for the user generated by the OpenAttribution SDK.
oa_uid (str, optional): The unique ID for the user generated by the OpenAttribution SDK.
If omitted for the first app_open, the server will generate one.
}


Returns:
-------
- None: The function does not return any value.
- dict[str, str]: The oa_uid used for the event. This lets the first
app_open receive a server-generated oa_uid.

Behavior
--------
Expand All @@ -335,11 +344,12 @@ async def events(
Example Usage
-------------
```
GET https://track.example.com/collect/events/com.example.app?event_id=level_1&event_time=1706499131&oa_uid=6a660ee7-bbc1-4440-9fdd-6564aca3560c&event_uid=6a660ee7-bbc1-4440-9fdd-6564aca3560c
POST https://track.example.com/collect/events/com.example.app
```

"""
client_host = get_client_ip(request)
oa_uid = data.oa_uid
ifa = data.ifa
if not is_valid_ifa(ifa):
if ifa is None:
Expand All @@ -354,7 +364,18 @@ async def events(
status_code=400,
detail="Invalid event_uid format, use a v4 UUID",
)
if not is_valid_uuid(data.oa_uid):
if oa_uid is None:
if data.event_id != "app_open":
raise HTTPException(
status_code=400,
detail="Missing oa_uid, only first app_open may omit it",
)
oa_uid = issue_oa_uid_for_first_open(
event_uid=data.event_uid,
store_id=app,
ifa=ifa,
)
elif not is_valid_uuid(oa_uid):
raise HTTPException(
status_code=400,
detail="Invalid oa_uid format, use a v4 UUID",
Expand All @@ -372,7 +393,7 @@ async def events(
DB_IFA: ifa,
APP_EVENT_REV: data.revenue,
DB_CLIENT_IP: client_host,
DB_OA_UID: data.oa_uid,
DB_OA_UID: oa_uid,
DB_EVENT_UID: data.event_uid,
DB_COUNTRY_ISO: country_iso,
DB_STATE_ISO: state_iso,
Expand All @@ -381,6 +402,7 @@ async def events(
}
event = EventData(**data)
to_kafka(event, "events")
return {DB_OA_UID: oa_uid}

@get(path="health")
async def health(self: Self) -> dict:
Expand Down
5 changes: 3 additions & 2 deletions apps/postback-api/api_app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,8 @@ class RequestEventData:
event_id (str): The string ID for a tracked event (e.g., 'tutorial', 'level_1')
event_time (int): The timestamp of the event
event_uid (str): Unique identifier for event deduplication
oa_uid (str): Unique user ID generated by OpenAttribution SDK
oa_uid (Optional[str]): Unique user ID generated by OpenAttribution SDK.
This may be omitted for the first app_open so the server can issue one.
ifa (Optional[str]): Identifier for Advertisers (optional)
revenue (Optional[str]): Revenue value in USD (optional)

Expand All @@ -185,6 +186,6 @@ class RequestEventData:
event_id: str
event_time: int
event_uid: str
oa_uid: str
oa_uid: str | None = None
ifa: str | None = None
revenue: str | None = None
55 changes: 55 additions & 0 deletions apps/postback-api/api_app/oa_uid.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""Helpers for issuing stable oa_uid values on first app_open."""

from api_app.tools import generate_oa_uid


def normalize_oa_uid_result(result: object | None) -> str | None:
"""Normalize persisted oa_uid values to strings at the boundary."""
return None if result is None else str(result)


def query_issued_oa_uid(event_uid: str) -> str | None:
"""Return any oa_uid already issued for this event uid."""
from dbcon.queries import query_oa_uid_issuance

return query_oa_uid_issuance(event_uid=event_uid)


def insert_issued_oa_uid(
event_uid: str,
oa_uid: str,
store_id: str,
ifa: str,
) -> str | None:
"""Persist a newly issued oa_uid, unless another request won the race."""
from dbcon.queries import insert_oa_uid_issuance

return insert_oa_uid_issuance(
event_uid=event_uid,
oa_uid=oa_uid,
store_id=store_id,
ifa=ifa,
)


def issue_oa_uid_for_first_open(event_uid: str, store_id: str, ifa: str) -> str:
"""Issue or reuse an oa_uid for a first app_open request."""
existing_oa_uid = query_issued_oa_uid(event_uid=event_uid)
if existing_oa_uid is not None:
return existing_oa_uid

new_oa_uid = generate_oa_uid()
inserted_oa_uid = insert_issued_oa_uid(
event_uid=event_uid,
oa_uid=new_oa_uid,
store_id=store_id,
ifa=ifa,
)
if inserted_oa_uid is not None:
return inserted_oa_uid

existing_oa_uid = query_issued_oa_uid(event_uid=event_uid)
if existing_oa_uid is None:
msg = f"Unable to issue oa_uid for first app_open {event_uid=}"
raise ValueError(msg)
return existing_oa_uid
5 changes: 5 additions & 0 deletions apps/postback-api/api_app/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ def generate_link_uid() -> str:
return str(uuid.uuid4())


def generate_oa_uid() -> str:
"""Generate a random OpenAttribution user id."""
return str(uuid.uuid4())


def is_valid_ifa(ifa: str | None) -> bool:
"""Check if a string is a valid ifa."""
if ifa is None:
Expand Down
34 changes: 34 additions & 0 deletions apps/postback-api/dbcon/queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from typing import cast

import pandas as pd
from api_app.oa_uid import normalize_oa_uid_result
from api_app.models import AppStores
from config import MODULE_DIR, get_logger
from litestar.stores.memory import MemoryStore
Expand All @@ -29,6 +30,8 @@ def load_sql_file(file_name: str) -> str:
QUERY_APP_LINKS = load_sql_file(
"app_links.sql",
)
QUERY_OA_UID_ISSUANCE = load_sql_file("query_oa_uid_issuance.sql")
INSERT_OA_UID_ISSUANCE = load_sql_file("insert_oa_uid_issuance.sql")


async def get_app_links() -> dict[str, dict[str, str]]:
Expand Down Expand Up @@ -72,6 +75,37 @@ async def get_apps() -> pd.DataFrame:
return df


def query_oa_uid_issuance(event_uid: str) -> str | None:
"""Return any oa_uid already issued for a first app_open event."""
with ENGINE.connect() as connection:
result = connection.execute(
QUERY_OA_UID_ISSUANCE,
{"event_uid": event_uid},
).scalar_one_or_none()
return normalize_oa_uid_result(result)


def insert_oa_uid_issuance(
event_uid: str,
oa_uid: str,
store_id: str,
ifa: str,
) -> str | None:
"""Insert a new issuance record, unless another request already did so."""
with ENGINE.connect() as connection:
result = connection.execute(
INSERT_OA_UID_ISSUANCE,
{
"event_uid": event_uid,
"oa_uid": oa_uid,
"store_id": store_id,
"ifa": ifa,
},
).scalar_one_or_none()
connection.commit()
return normalize_oa_uid_result(result)


logger.info("set db engine")
DBCON = get_db_connection()
DBCON.set_engine()
Expand Down
14 changes: 14 additions & 0 deletions apps/postback-api/dbcon/sql/insert_oa_uid_issuance.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
INSERT INTO oa_uid_issuances (
event_uid,
oa_uid,
store_id,
ifa
)
VALUES (
:event_uid,
:oa_uid,
:store_id,
:ifa
)
ON CONFLICT (event_uid) DO NOTHING
RETURNING oa_uid;
4 changes: 4 additions & 0 deletions apps/postback-api/dbcon/sql/query_oa_uid_issuance.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
SELECT oa_uid
FROM oa_uid_issuances
WHERE event_uid = :event_uid
LIMIT 1;
Loading