# Гайд: Прямая API-интеграция для Flutter

Этот гайд предназначен для Flutter-разработчиков, которым нужен полный контроль над HTTP-вызовами, состоянием, рендерингом и трекингом кампаний Gravity Field без использования готового Flutter SDK UI-слоя.

Под "прямой API-интеграцией" здесь понимается подход, в котором ваше приложение:

  • само вызывает /visit, /event, /choose и tracking URL;
  • само хранит uid и ses;
  • само рендерит in-app или inline-контент;
  • само отправляет engagement-события.

Если вам нужен готовый UI-слой, автоматический показ in-app кампаний и встроенная обработка tracking, используйте Flutter SDK. Если вам нужен SDK без автопоказа и с кастомным UI, смотрите Headless-режим.

# 1. Быстрый старт

Этот раздел повторяет паттерн других SDK-гайдов: сначала короткая последовательность шагов, по которой можно запустить первую working integration, а подробные объяснения и edge cases разобраны ниже.

# Шаг 1: Настройка API-клиента

Сначала настройте HTTP-клиент с Authorization, sec, базовым URL и короткими таймаутами.

final dio = Dio(
  BaseOptions(
    baseUrl: 'https://evs-01.gravityfield.ai/v2',
    connectTimeout: const Duration(milliseconds: 300),
    receiveTimeout: const Duration(milliseconds: 800),
    sendTimeout: const Duration(milliseconds: 300),
    headers: {
      'Authorization': 'Bearer YOUR_API_KEY',
      'Content-Type': 'application/json',
    },
  ),
);

const sectionId = 'YOUR_SECTION_ID';

На этом шаге цель простая: получить рабочий transport-слой, через который вы будете вызывать /visit, /event, /choose и tracking URL.

# Шаг 2: Идентификация пользователя

На этом шаге важно понять, в каком режиме идентификации работает приложение. В direct API есть два слоя:

  • uid и ses - базовый анонимный слой идентификации на уровне текущего устройства и сессии;
  • cuid и cuidType - слой омниканальной идентификации для склейки профиля между web, mobile app и офлайном.

Практически у Flutter-приложения есть три режима работы:

  1. Анонимный режим Приложение ещё не знает постоянного идентификатора клиента. На первом запросе можно не передавать uid, сервер сам создаст его и вернёт в user. После этого:
    • uid нужно сохранить между сессиями;
    • ses нужно хранить только в памяти текущего запуска приложения.
  2. Авторизованный режим через login-v1 / signup-v1 Это рекомендуемый режим для mobile app. После логина приложение отправляет login-v1 с cuid + cuidType. Для основной интеграции рекомендуйте phone_hash как cuidType.
  3. Режим с внешним ID приложения Если у приложения есть собственный устойчивый user ID, его можно использовать внутри своей системы и, при необходимости, передавать во внешний идентификатор профиля. В v2 direct API для этого используется поле user.custom; в общей server-side терминологии ту же роль часто описывают как user.id или extUid. Но этот ID не заменяет cuid как основной механизм омниканальной склейки между каналами.

Рекомендуемая модель по умолчанию:

  • пользователь новый и не логинился -> работаем в анонимном режиме;
  • пользователь залогинился -> отправляем login-v1 и включаем омниканальную идентификацию;
  • если приложение знает только локальный user ID -> не подменяем им cuid, а используем отдельно как внешний ID приложения.

Бизнес-смысл:

  • uid нужен для непрерывности поведения на одном устройстве;
  • cuid нужен для объединения поведения одного и того же клиента между web, mobile и офлайном;
  • без login-v1 приложение может корректно работать, но останется только в device-level идентификации.

Техническое правило хранения при этом простое:

  • uid сохранить в persistent storage;
  • ses держать только в memory;
  • uid и ses обновлять из объекта user после ключевых ответов API.
Future<void> saveUserFromResponse(
  Map<String, dynamic> response,
  GravitySessionStore store,
) async {
  final user = response['user'] as Map<String, dynamic>?;
  final uid = user?['uid'] as String?;
  final ses = user?['ses'] as String?;

  if (uid != null) await store.writeUid(uid);
  if (ses != null) store.writeSessionId(ses);
}

Минимальный пример перехода из анонимного режима в авторизованный:

final loginEvent = {
  'type': 'login-v1',
  'name': 'Login',
  'cuid': hashedPhone,
  'cuidType': 'phone_hash',
};

Подробно про роль uid, ses, cuid, cuidType и внешнего user ID см. ниже в разделе про общие примитивы интеграции.

# Шаг 3: Передача просмотров экранов через /visit

Когда пользователь открывает экран, отправьте screenview через /visit с корректным PageContext. См. OpenAPI /visit.

await dio.post(
  '/visit',
  data: {
    'sec': sectionId,
    'user': {
      'uid': await store.readUid(),
      'ses': store.readSessionId(),
    },
    'ctx': {
      'type': 'PRODUCT',
      'data': ['sku-123'],
      'location': 'app://product/sku-123',
    },
    'device': device,
    'type': 'screenview',
  },
);

PageContext должен совпадать с таргетингом и продуктовым фидом. Если кампания настроена на PRODUCT, а приложение отправляет OTHER, контент просто не сработает. Подробно про контексты и требования к значениям см. ниже.

# Шаг 4: Передача пользовательских событий через /event

Когда пользователь совершает значимое действие, отправьте бизнес-событие через /event. См. OpenAPI /event.

await dio.post(
  '/event',
  data: {
    'sec': sectionId,
    'user': {
      'uid': await store.readUid(),
      'ses': store.readSessionId(),
    },
    'ctx': {
      'type': 'OTHER',
      'data': [],
      'location': 'app://login',
    },
    'device': device,
    'data': [
      {
        'type': 'login-v1',
        'name': 'Login',
        'cuid': hashedPhone,
        'cuidType': 'phone_hash',
      },
      {
        'type': 'survey-completed-v1',
        'name': 'Survey Completed',
        'customProps': {
          'surveyId': 'summer-2025-feedback',
        },
      },
    ],
  },
);

login-v1 нужен не только для аналитики, а для омниканальной склейки профилей. Для кастомных событий в v2 API используйте customProps, а не properties.

# Шаг 5: Получение контента

См. OpenAPI /choose.

На этом шаге выберите один из двух флоу:

  1. in-app: сначала /visit или /event, затем взять campaignId из ответа и вызвать /choose(campaignId).
  2. inline / API / A/B: если placement известен заранее, вызвать /choose(selector) напрямую.
// in-app
await dio.post('/choose', data: {
  'sec': sectionId,
  'user': {'uid': uid, 'ses': ses},
  'ctx': ctx,
  'device': device,
  'options': {'isBuildEngagementUrl': true},
  'data': [
    {'campaignId': campaignId}
  ],
});

// inline / API / A/B
await dio.post('/choose', data: {
  'sec': sectionId,
  'user': {'uid': uid, 'ses': ses},
  'ctx': ctx,
  'device': device,
  'options': {'isBuildEngagementUrl': true},
  'data': [
    {'selector': 'homepage-recommendations'}
  ],
});

Если вам нужно встроить конкретный placement, selector flow обычно проще. Если кампания должна активироваться действием или screenview, нужен campaignId flow. Подробное различие между режимами описано ниже.

После /choose следующий обязательный шаг - передать данные в ваш UI-слой. Минимальный путь до рендеримого блока такой:

  • data[0] - кампания;
  • data[0].payload[0] - выбранная вариация;
  • data[0].payload[0].contents[0] - блок контента для рендера;
  • data[0].payload[0].decisionId - идентификатор решения;
  • contents[0].events - content-level tracking;
  • contents[0].products.slots - товары, если в контенте есть recommendation-блок.

Важно: ответ /choose тоже может вернуть обновлённый user. Сохраняйте uid и ses из этого ответа так же, как после /visit и /event, чтобы не потерять консистентность сессии.

# Шаг 6: Ручной engagement и обработчики on*

После рендера вы обязаны вручную отправлять engagement:

  • onImpression
  • onVisibleImpression
  • element.onClick
  • slot.events[] для карточек товаров
final variables = content['variables'] as Map<String, dynamic>?;
final onImpression = variables?['onImpression'] as Map<String, dynamic>?;
final action = onImpression?['action'] as String?;

if (action == null) return;

final urls = findContentTrackingUrls(
  events: content['events'] as List<dynamic>?,
  action: action,
);

if (urls.isNotEmpty) {
  await triggerTrackingUrls(urls);
}

Для product cards используйте отдельный источник tracking - slot.events[]. Без этого у кампаний не будет корректной статистики, а результаты A/B тестов и CTR будут искажены. Подробно про events[], slot.events[], onVisibleImpression и другие обработчики см. в отдельном разделе ниже.

# Схема первого запуска

screenview -> /visit -> campaigns -> /choose(campaignId) -> render -> engagement
event -> /event -> campaigns -> /choose(campaignId) -> render -> engagement
selector -> /choose(selector) -> render -> engagement

После этого быстрого старта переходите к подробным разделам ниже:

  • про OpenAPI, MCP и основные источники контракта;
  • про различие campaignId-flow и selector-flow;
  • про uid / ses / cuid;
  • про engagement, content.events[] и slot.events[].

# 2. Источники спецификации и зачем они нужны

При реализации прямой API-интеграции ориентируйтесь сразу на несколько источников:

  • OpenAPI v2 - официальный wire contract API: какие endpoints доступны, какие поля принимаются и что возвращается в ответе.
  • SDK API MCP - быстрый способ посмотреть актуальные path/schema details без ручного поиска по Swagger.
  • gravity-sdk-flutter - reference implementation клиентского поведения: как обрабатываются priority, delayTime, onVisibleImpression, content.events[] и slot.events[].

# 2.1. MCP для просмотра API

Если вы используете AI-ассистента или IDE с MCP, можно подключить сервер SDK API:

{
  "mcpServers": {
    "SDK API": {
      "command": "npx",
      "args": [
        "-y",
        "apidog-mcp-server@latest",
        "--site-id=748185"
      ]
    }
  }
}

Это полезно для быстрой сверки актуальной OpenAPI-спецификации: какие поля реально принимает /visit, /event, /choose, как называются options и какие схемы возвращаются в ответе.

# 3. Два режима работы с кампаниями

В прямой API-интеграции есть два разных сценария, и их важно не смешивать.

Сценарий Когда использовать Какой первый запрос Как получается контент
in-app activation flow Кампания должна сработать на screenview или бизнес-событие /visit или /event /choose по campaignId
inline / API / A/B selector flow Placement известен заранее, приложение само решает, где рендерить контент /choose по selector Контент возвращается сразу

# 3.1. In-app через активацию

Этот сценарий нужен для modal, full screen, bottom sheet, snackbar и любых кампаний, где сначала должна сработать логика активации:

  • по экрану;
  • по действию пользователя;
  • по сегменту;
  • по частотным ограничениям;
  • по приоритету кампаний.

Цепочка выглядит так:

  1. Приложение отправляет /visit или /event.
  2. API возвращает user и список campaigns.
  3. Клиент выбирает подходящую кампанию, учитывая priority и delayTime.
  4. Приложение вызывает /choose по campaignId.
  5. Приложение рендерит контент и отправляет engagement.

# 3.2. Inline / API / A/B по selector

Этот сценарий нужен, когда placement на экране заранее известен:

  • inline-блок рекомендаций;
  • встраиваемый banner slot;
  • API-кампания типа Custom JSON;
  • selector-based A/B тест;
  • headless use case, где приложение само решает, как использовать response.

Цепочка выглядит так:

  1. Приложение знает selector.
  2. Приложение вызывает /choose по selector.
  3. API возвращает контент для этого placement-а.
  4. Приложение рендерит контент и отправляет engagement.

Важно: selector flow не заменяет activation flow для in-app сценариев. Если кампания должна запускаться по screenview или по событию, сначала нужен /visit или /event.

# 4. Общие примитивы интеграции

# 4.1. uid, ses, cuid, cuidType

Для прямой интеграции важно понимать разницу между идентификаторами:

  • uid - внутренний идентификатор пользователя в Gravity Field. Его нужно сохранять между сессиями.
  • ses - идентификатор текущей сессии. Его нужно хранить в памяти на время жизни app session.
  • cuid и cuidType - устойчивый идентификатор клиента для омниканальной склейки профилей. Обычно используется в login-v1 или signup-v1.

Бизнес-смысл:

  • uid и ses нужны для стабильного трекинга внутри приложения;
  • cuid нужен для склейки профиля между web, mobile app, backend и офлайн-данными.

Рекомендуемый сценарий идентификации:

  1. До логина приложение работает с анонимным uid.
  2. После логина приложение отправляет login-v1.
  3. В событии передается cuid и cuidType.
  4. Gravity Field склеивает анонимный профиль с омниканальным профилем клиента.

Этого достаточно для direct API интеграции: анонимный uid/ses для device-level continuity и login-v1 с cuid для омниканальной склейки.

# 4.2. PageContext

PageContext нужен не только для transport-уровня. Это ключевая часть бизнес-логики:

  • по нему работает таргетинг кампаний;
  • по нему запускаются in-app кампании;
  • по нему подбираются рекомендации;
  • его значения должны совпадать с данными продуктового фида.

Основные типы контекста:

ctx.type Когда использовать Что передавать в ctx.data
HOMEPAGE Главный экран []
PRODUCT Карточка товара [sku]
CATEGORY Категория Иерархию категорий из фида
CART Корзина SKU всех товаров в корзине
SEARCH Поиск Поисковый запрос
OTHER Любой другой экран [] или технический маркер

Практические правила:

  • productId, SKU, категории и lng должны совпадать с продуктовым фидом;
  • не меняйте регистр значений, если они берутся из фида;
  • location должен быть стабилен и уникально описывать экран или placement.

PageContext должен быть согласован с тем, как у вас настроены таргетинг и продуктовый фид. В этом гайде дальше используются только актуальные для v2 direct API поля ctx.type, ctx.data и ctx.location.

# 4.3. options и contentSettings

В v2 API для /choose и частично для /visit//event используются options.

Практически важные поля:

  • isReturnCounter
  • isReturnUserInfo
  • isReturnAnalyticsMetadata
  • isImplicitPageview
  • isBuildEngagementUrl

Для прямой API-интеграции обычно важно явно включать:

  • isBuildEngagementUrl: true, если вы хотите получить tracking URL в events[].urls[];
  • isReturnAnalyticsMetadata: true, если вам нужны дополнительные аналитические метаданные.

Для campaign request data в /choose можно передавать content options, например:

  • skusOnly
  • fields

Это полезно для inline recommendations, если вам нужно ограничить набор полей товара.

# 4.4. Пример базового API-клиента

import 'package:dio/dio.dart';

class GravityApiClient {
  GravityApiClient({
    required this.apiKey,
    required this.sectionId,
  }) : dio = Dio(
          BaseOptions(
            baseUrl: 'https://evs-01.gravityfield.ai/v2',
            connectTimeout: const Duration(milliseconds: 300),
            receiveTimeout: const Duration(milliseconds: 800),
            sendTimeout: const Duration(milliseconds: 300),
            headers: const {
              'Content-Type': 'application/json',
            },
          ),
        ) {
    dio.interceptors.add(
      InterceptorsWrapper(
        onRequest: (options, handler) {
          options.headers['Authorization'] = 'Bearer $apiKey';
          handler.next(options);
        },
      ),
    );
  }

  final Dio dio;
  final String apiKey;
  final String sectionId;
}

# 4.5. Хранение uid и ses

abstract class GravitySessionStore {
  Future<String?> readUid();
  Future<void> writeUid(String uid);

  String? readSessionId();
  void writeSessionId(String sessionId);
  void clearSessionId();
}

Рекомендуемая стратегия:

  • uid хранить между перезапусками приложения;
  • ses хранить только в памяти процесса;
  • после cold start ses начинать с null, чтобы сервер выдал новую сессию.

# 5. Сценарий A: in-app через /visit или /event

# 5.1. Когда использовать /visit

/visit нужен для передачи screenview/pageview контекста, используйте type: screenview.

Это основной запрос, если кампания должна срабатывать:

  • при открытии экрана;
  • при переходе на PDP, cart, homepage, category;
  • при попадании пользователя в контекст, на который настроен таргетинг.

# 5.2. Пример /visit

Future<Map<String, dynamic>> postVisit({
  required GravityApiClient api,
  required GravitySessionStore store,
  required Map<String, dynamic> device,
  required Map<String, dynamic> ctx,
}) async {
  final payload = {
    'sec': api.sectionId,
    'user': {
      'uid': await store.readUid(),
      'ses': store.readSessionId(),
    },
    'ctx': ctx,
    'device': device,
    'type': 'screenview',
    'options': {
      'isReturnCounter': false,
      'isReturnUserInfo': false,
    },
  };

  final response = await api.dio.post('/visit', data: payload);
  final data = response.data as Map<String, dynamic>;

  final user = data['user'] as Map<String, dynamic>?;
  final uid = user?['uid'] as String?;
  final ses = user?['ses'] as String?;

  if (uid != null) await store.writeUid(uid);
  if (ses != null) store.writeSessionId(ses);

  return data;
}

# 5.3. Когда использовать /event

/event нужен для бизнес-событий:

  • purchase-v1
  • add-to-cart-v1
  • remove-from-cart-v1
  • login-v1
  • любые кастомные события

Это важно не только для аналитики. Эти события участвуют в логике активации кампаний и сегментации.

# 5.4. Пример /event

Future<Map<String, dynamic>> postEvent({
  required GravityApiClient api,
  required GravitySessionStore store,
  required Map<String, dynamic> device,
  required Map<String, dynamic> ctx,
  required List<Map<String, dynamic>> events,
}) async {
  final payload = {
    'sec': api.sectionId,
    'user': {
      'uid': await store.readUid(),
      'ses': store.readSessionId(),
    },
    'ctx': ctx,
    'device': device,
    'data': events,
    'options': {
      'isReturnCounter': false,
      'isReturnUserInfo': false,
    },
  };

  final response = await api.dio.post('/event', data: payload);
  final data = response.data as Map<String, dynamic>;

  final user = data['user'] as Map<String, dynamic>?;
  final uid = user?['uid'] as String?;
  final ses = user?['ses'] as String?;

  if (uid != null) await store.writeUid(uid);
  if (ses != null) store.writeSessionId(ses);

  return data;
}

# Пример login-v1 для склейки профилей

final loginEvent = {
  'type': 'login-v1',
  'name': 'Login',
  'cuid': hashedPhone,
  'cuidType': 'phone_hash',
};

# Пример кастомного события

final customEvent = {
  'type': 'survey-completed-v1',
  'name': 'Survey Completed',
  'customProps': {
    'surveyId': 'summer-2025-feedback',
    'rating': '5',
  },
};

# 5.5. Как выбрать кампанию из campaigns

После /visit или /event вы получаете массив campaigns.

Практическое правило, повторяющее поведение Flutter SDK:

  1. Отсортируйте кампании по priority по убыванию.
  2. Для каждой кампании попробуйте получить контент через /choose(campaignId).
  3. Если у кампании есть delayTime > 0, дождитесь задержки перед показом.
  4. Используйте первую кампанию, для которой реально пришёл контент.
Map<String, dynamic>? pickCampaignToRender(List<dynamic> campaigns) {
  final sorted = [...campaigns]..sort(
    (a, b) => (b['priority'] as int).compareTo(a['priority'] as int),
  );

  return sorted.isEmpty ? null : sorted.first as Map<String, dynamic>;
}

# 5.6. Пример /choose по campaignId

Future<Map<String, dynamic>> chooseByCampaignId({
  required GravityApiClient api,
  required GravitySessionStore store,
  required Map<String, dynamic> device,
  required Map<String, dynamic> ctx,
  required String campaignId,
}) async {
  final payload = {
    'sec': api.sectionId,
    'user': {
      'uid': await store.readUid(),
      'ses': store.readSessionId(),
    },
    'ctx': ctx,
    'device': device,
    'options': {
      'isReturnCounter': false,
      'isReturnUserInfo': false,
      'isReturnAnalyticsMetadata': true,
      'isImplicitPageview': false,
      'isBuildEngagementUrl': true,
    },
    'data': [
      {
        'campaignId': campaignId,
        'option': {
          'skusOnly': false,
          'fields': ['name', 'price', 'imageUrl'],
        },
      },
    ],
  };

  final response = await api.dio.post('/choose', data: payload);
  return response.data as Map<String, dynamic>;
}

# 5.7. Полный flow

sequenceDiagram
    participant App as Flutter App
    participant API as Gravity API

    App->>API: POST /visit или POST /event
    API-->>App: user + campaigns[]
    App->>App: sort by priority, apply delayTime
    App->>API: POST /choose { campaignId }
    API-->>App: data[] + payload[] + contents[] + events[]
    App->>App: render custom UI
    App->>API: GET tracking URL(s)

# 6. Сценарий B: inline, Custom JSON и selector-based A/B через /choose(selector)

Этот режим нужен, когда приложение заранее знает placement:

  • "Вам может понравиться" на homepage;
  • блок рекомендаций на PDP;
  • selector-based A/B тест кнопки, баннера или блока;
  • Custom JSON для BDUI и API-кампаний.

Бизнес-смысл совпадает с другими разделами документации про personalization и hybrid A/B: selector flow используется там, где приложение или backend заранее знает точку встраивания контента.

# 6.1. Пример /choose по selector

Future<Map<String, dynamic>> chooseBySelector({
  required GravityApiClient api,
  required GravitySessionStore store,
  required Map<String, dynamic> device,
  required Map<String, dynamic> ctx,
  required String selector,
}) async {
  final payload = {
    'sec': api.sectionId,
    'user': {
      'uid': await store.readUid(),
      'ses': store.readSessionId(),
    },
    'ctx': ctx,
    'device': device,
    'options': {
      'isReturnCounter': false,
      'isReturnUserInfo': false,
      'isReturnAnalyticsMetadata': true,
      'isImplicitPageview': false,
      'isBuildEngagementUrl': true,
    },
    'data': [
      {
        'selector': selector,
        'option': {
          'skusOnly': false,
          'fields': ['name', 'price', 'imageUrl'],
        },
      },
    ],
  };

  final response = await api.dio.post('/choose', data: payload);
  final data = response.data as Map<String, dynamic>;

  final user = data['user'] as Map<String, dynamic>?;
  final uid = user?['uid'] as String?;
  final ses = user?['ses'] as String?;

  if (uid != null) await store.writeUid(uid);
  if (ses != null) store.writeSessionId(ses);

  return data;
}

# 6.2. Что делать с ответом

Практически полезный путь до контента:

  • data[0] - кампания;
  • data[0].payload[0] - выбранная вариация;
  • data[0].payload[0].contents[0] - блок контента;
  • data[0].payload[0].decisionId - идентификатор решения;
  • contents[0].events - content-level tracking;
  • contents[0].products.slots[*].events - product-level tracking.

# 6.3. Fallback

Для inline и API/A/B placement-ов всегда закладывайте fallback:

  • если /choose вернул пустой data;
  • если товарная кампания вернула пустые slots;
  • если запрос не успел уложиться в таймаут;
  • если контент не удалось распарсить.

Обычно fallback выглядит так:

  • не отображать блок;
  • показать заглушку;
  • показать стандартный banner;
  • показать статический набор товаров.

# 7. Engagement, events[] и обработчики on*

Это критически важная часть прямой интеграции. Без engagement у кампаний не будет корректной статистики, а A/B результаты будут неполными или неверными.

Ниже описан рекомендуемый для v2 direct API подход: использовать tracking URL из events[].urls[] и slot.events[].urls[], а не выносить tracking-логику в отдельный reference-поток.

# 7.1. Главная концепция

В ответе /choose есть два источника tracking:

  • content.events[] - tracking для всего контентного блока;
  • products.slots[].events[] - tracking для отдельных товарных карточек.

Принцип работы всегда один и тот же:

  1. UI получает action из variables.onLoad, onImpression, onVisibleImpression, onClose или element.onClick.
  2. По этому action приложение находит подходящий объект в content.events[].
  3. Приложение вызывает все URL из urls[].

Для товарных карточек логика такая же, но источник событий другой: slot.events[].

Важно:

  • у content-level модели нет универсального обязательного click; она строится вокруг action;
  • для product-level tracking модель проще: impression, visible_impression, click.

# 7.2. Что и когда отправлять

Что отправлять Когда
onLoad После успешной загрузки контента
onImpression После первого фактического рендера блока
onVisibleImpression Один раз, когда блок реально стал видим пользователю
onClose Когда пользователь закрыл in-app блок
slot.visible_impression Один раз, когда карточка товара стала видимой
slot.click На тап по карточке товара

Для visible_impression придерживайтесь той же модели, что и SDK: отправляйте событие один раз, когда элемент достиг как минимум 50% видимости.

# 7.3. Короткий пример content tracking

Future<void> triggerTrackingUrls(List<String> urls) async {
  final trackingDio = Dio();

  for (final url in urls) {
    try {
      await trackingDio.get(url);
    } catch (_) {
      // Не ломаем UI из-за tracking ошибки.
    }
  }
}

Future<void> sendContentTracking({
  required Map<String, dynamic> content,
  required String action,
}) async {
  final events = content['events'] as List<dynamic>?;
  if (events == null) return;

  for (final rawEvent in events) {
    final event = rawEvent as Map<String, dynamic>;
    if (event['type'] == action && event['urls'] is List) {
      final urls = (event['urls'] as List).map((e) => e.toString()).toList();
      await triggerTrackingUrls(urls);
      return;
    }
  }
}

Практически это выглядит так:

  • variables.onImpression.action -> sendContentTracking(...)
  • variables.onVisibleImpression.action -> sendContentTracking(...)
  • element.onClick.action -> сначала sendContentTracking(...), потом ваше UI-действие

# 7.4. Короткий пример product tracking

Future<void> sendProductTracking({
  required Map<String, dynamic> slot,
  required String action,
}) async {
  final events = slot['events'] as List<dynamic>?;
  if (events == null) return;

  for (final rawEvent in events) {
    final event = rawEvent as Map<String, dynamic>;
    if (event['type'] == action && event['urls'] is List) {
      final urls = (event['urls'] as List).map((e) => e.toString()).toList();
      await triggerTrackingUrls(urls);
      return;
    }
  }
}

Обычно этого достаточно:

  • sendProductTracking(slot: slot, action: 'visible_impression')
  • sendProductTracking(slot: slot, action: 'click')

# 8. Практические рекомендации

# 8.1. Таймауты и fallback

  • Для inline placement-ов и A/B используйте строгие таймауты.
  • Не блокируйте UI из-за /choose.
  • Если API не ответил вовремя, показывайте fallback.

# 8.2. Не отправляйте engagement из build()

impression и visible_impression нужно защищать флагами и отправлять один раз. Иначе вы получите дубли.

# 8.3. Не меняйте значения из фида

Если ctx.data, productId, категории или lng берутся из фида, передавайте их без нормализации и без смены регистра.

# 8.4. Отдельно тестируйте пустые ответы

Нормальная ситуация:

  • /visit вернул пустой campaigns;
  • /event вернул пустой campaigns;
  • /choose вернул пустой data;
  • товарный блок вернул пустые slots.

Это не ошибка интеграции само по себе. В таких случаях приложение должно корректно показывать fallback.