サーバー間 SKAdNetwork 3 導入ガイド

ドキュメント
新バージョンのSKAdNetworkの実装については、SKAdNetwork 4実装ガイドをご参照ください。

SKAdNetworkの概要

SKAdNetworkは、iOSのアプリインストール広告キャンペーンのプライバシーに配慮した測定を可能にするためにAppleが提供するフレームワークです(詳細はこちら)。このフレームワークは、ユーザーの識別情報を損なうことなく、アプリインストールキャンペーンのコンバージョン率を測定するのに役立ちます。

SKAdNetworkの仕組み

SKAdNetworkでは、アトリビューションプロセスはApp StoreがAppleのサーバーを通じて行います。そして、アトリビューション情報はユーザー識別子や時間情報から切り離され、ネットワークに送られます。

Screen_Shot_2020-09-16_at_18.57.56.png

広告がクリックされ、ストアが開かれると、パブリッシングアプリとネットワークは、ネットワーク、パブリッシャー、キャンペーンIDなどの基本情報を提供する。広告主アプリが起動し、SKAdNetworkに登録されると、デバイスはコンバージョン成功の通知をネットワークに送信します。広告アプリが報告できるコンバージョン値と一緒に、添付された値が報告されます。

この通知は、最初の起動から少なくとも24時間後に送信され、デバイスやユーザーを特定する情報は一切含まれません。

さらに、App Storeは、広告されたアプリが元の広告やパブリッシャーについて知らないように処理を行います。このようにして、インストールするユーザーについて何も知ることなく、ネットワークにインストールが通知されます。

SKAdNetworkを利用する際の注意点

SKAdNetworkにはいくつかの大きな利点があります。それは以下の全ての情報を提供します:

  • 同意なしで機能するラストクリックのアトリビューション
  • ソース、キャンペーン、パブリッシャーの内訳
  • インストール後のコンバージョン値(最大64の離散値)
  • インストールを検証するための暗号署名

しかし、現在の形では、SKAdNetworkは基本的なものであり、確実に機能させるためには、慎重な実装と複数のエンティティ間の調整が必要です。

以下は、SKAdNetworkの現在の制限事項である:

  • ユーザーレベルのデータがない
  • ビュースルーアトリビューションなし
  • コンバージョン値の範囲が限定されている:
    • インストール/再インストールごとに1つの変換イベント
    • 最大64の変換値(6ビット)
  • 粒度の制限:
    • 最大100キャンペーン値
    • 広告グループとクリエイティブレベルの表現なし
  • LTV/ロングコホートなし
  • 不正の可能性:
    • コンバージョン値自体が署名されていない(操作可能)
    • ポストバックの複製が可能

これらの問題を克服するために、SingularはSKAdNetwork実装のためのパブリックスタンダードと、SKAdNEtworkをナビゲートするためのいくつかのブログ記事をリリースしました:

SKAdNetwork S2Sの実装

SKAdNetworkソリューションは以下のコンポーネントで構成されています:

  • SKAdNetworkを実装するクライアント側のコード
  • 全てのネットワークからのポストバック検証及びアグリゲーション
  • 不正防止:
    • 署名検証とトランザクションIDのデデューピング
    • 署名されていないパラメータ(コンバージョン値とジオデータ)を検証するためのネットワークとのセキュアなセットアップ。
  • コンバージョン値の管理: Singularのダッシュボードで、どのインストール後のアクティビティをSKAdNetworkのコンバージョン値にエンコードするかを動的に設定できます。
  • レポーティング:限られたSKAdNetworkキャンペーンIDを変換し、より多くのマーケティングパラメータと粒度でデータを充実させます。
  • パートナーのポストバック: SKAdNetworkのポストバックを、イベントと収益にデコードされたコンバージョン値で送信します。

サーバー間のSingularインテグレーションをご利用のお客様には、SKAdNetworkの実装は主に2つの部分で構成されています:

  1. SKAdNetworkクライアント側の実装: SKAdNetworkクライアント側の実装:この部分は、お客様のアプリをSKAdNetworkに登録し、SKAdNetworkのコンバージョン値をインテリジェントに管理するために重要です。つまり、これを実装することで、SKAdNetworkのアトリビューションと関連するインストール後のアクティビティに基づいて、キャンペーンを最適化できるようになります。
  2. S2S統合アップデート(オプション):このパートは、クライアントサイドの実装を検証し、トラブルシューティングするために重要です。SingularのS2Sセッションとイベントエンドポイントを経由して今日SIngularに送られるイベントとセッションをSKAdNetworkデータ(コンバージョン値と更新タイムスタンプ)でエンリッチすることで、Singularはアプリ側で実装が適切に行われたことを検証することができます。

SKAdNetworkクライアント側の実装

SingularはSKAdNetworkへの登録とコンバージョン値の管理をサポートするコードサンプルを提供しています。これらのコードサンプルは以下の部分を担当しています:

  1. SKAdNetworkのサポートと登録
  2. 変換値の管理:
    • このコードはSingularのエンドポイントと同期的に通信し、設定されたコンバージョンモデルに基づいて次のコンバージョン値を受け取ります。イベント/セッション/収益を報告し、その応答として次のコンバージョン値を取得します。この値は、Singularのダッシュボードで測定用に設定されたインストール後のアクティビティを表すエンコードされた数値です。
    • このコードはまた、SKAdnetworkメタデータを収集し、検証と次のコンバージョン値の計算の両方に使用される:
      • 基礎となるSKAdNetworkフレームワークへの最初の呼び出しタイムスタンプ
      • 基礎となるSKAdNetworkフレームワークへの最初の呼び出しタイムスタンプ。
      • 最後に更新されたコンバージョン値

S2S統合アップデート

必須

コンバージョンモデルがアクティブになると、S2Sエンドポイントは "conversion_value "という新しいintフィールドを返すようになります。

オプション

統合を検証し、潜在的な実装上の問題をトラブルシュートするために、現在のS2S統合を拡張するコードサンプルを使用することをお勧めします。SingularのS2Sイベントとセッションエンドポイントは、既に以下のような追加SKAdNetworkパラメータの取得をサポートしています:

  • 最新のコンバージョン値
  • SKAdNetworkフレームワークへの呼び出しの最後のタイムスタンプ
  • 基礎となるSKAdNetworkフレームワークへの呼び出しの最初のタイムスタンプ

統合フロー

Screen_Shot_2020-09-16_at_18.59.13.png

上図は、S2S顧客のSKAdNetworkフローを示しています:

  1. まず、アプリ内のコードがSingularサーバーと通信し(SKAdNetwork専用のエンドポイントを通して)、アプリ内で発生したイベント/セッション/収益イベントに基づいて最新のコンバージョン値を同期的に取得し、この値でSKAdNetworkフレームワークを更新します。
  2. 次に、アプリは既存のイベントやセッションをSKAdNetworkのデータでエンリッチします。
  3. アプリが新しい変換値でSKAdNetworkフレームワークを更新し終わり、SKAdNetworkタイマーが切れると、SKAdNetworkポストバックがネットワークに送信されます。
  4. ネットワークはこのポストバックをSingularに転送します(セキュアセットアップまたは通常セットアップ)。
  5. Singularはポストバックを以下のように処理します:
    • 署名の検証
    • 設定された変換モデルに基づいて変換値をデコードする。
    • パートナーとの統合やSKAdNetworkの内部キャンペーンIDに基づいて、より外向きのマーケティングパラメータでポストバックをエンリッチします。
    • デコードされたポストバックをBIとパートナーに送信します。

重要な注意点として、インストールやデコードされたイベントを含むSKAdNetworkの情報は、既存のデータセットとの混合を避けるために、別のレポート/API/ETLテーブルとポストバックのセットでアクセスできるようになります。これは、SKAdNetworkを既存のキャンペーンと並行して測定・テストするために、次の数週間は特に重要です。

SKAdNetworkネイティブiOSコードサンプル

SKAdNetwork インターフェース

このインターフェースには以下のSKAdNetworkコンポーネントが含まれています:

SKAdNetwork への登録:

  • このメソッドは SKAdNetwork にアプリを登録します。基礎となるApple APIメソッドは、デバイスがそのアプリのアトリビューションデータを持っている場合、通知を生成し、24時間タイマーを開始します。
  • デバイスはタイマーが切れた後、0~24時間以内にインストール通知を広告ネットワークのポストバックエンドポイントに送信します。
  • コンバージョン値を前回よりも大きな値で更新すると、最初のタイマーがリセットされ、新しい24時間間隔になることに注意してください(詳しくはこちら)。

換算値の管理と更新

  • コンバージョン値は、以下のメソッドによって取得されたデバイスのインストール後のアクティビティと、ユーザーが動的に設定できる選択されたコンバージョンモデルに基づいて計算されます。
  • このセクションのメソッドは、選択されたコンバージョンモデルと報告されたインストール後のアクティビティに従って、Singularのエンドポイントから次のコンバージョン値を取得します(上記のドキュメントを参照)。
  • 以下のメソッドは、以下のインストール後のアクティビティに基づいてコンバージョン値を更新します:
      • セッション:SKAdNetworkでのユーザーのリテンション測定に重要です。
      • コンバージョンイベント: SKAdNetworkでのインストール後のコンバージョンイベント測定に重要です。
      • 収益イベント:SKAdNetwork での収益測定に重要。
//  SKANSnippet.h


#import <Foundation/Foundation.h>

@interface
SKANSnippet : NSObject

// Register for SKAdNetwork attribution.

// You should call this method as soon as possible once app is launched

+ (void)registerAppForAdNetworkAttribution;

// To track retention and cohorts you need to call this method after 

// each session. It reports the session details and updates the conversion 

// value due to this session if needed. The conversion value will be 

// updated only if the new value is greater than the previous value.

// The callback passed to the method is optional, you can use it to 

// run code once the conversion value is updated. 

+ (void)updateConversionValueAsync:(void(^)(int, NSError*))handler;

// To track conversion events with SKAdNetwork you need to call this 

// method after each event. It reports the event details and updates the 

// conversion value due to this event if needed.  

// The callback passed to the method is optional, you can use it to 

// run code once the conversion value is updated.

+ (void)updateConversionValueAsync:(NSString*)eventName
  withCompletionHandler:(void(^)(int, NSError*))handler;

// To track revenue with SKAdNetwork you need to call this 

// method before each  revenue event .

// It will update the total revenue, so when you call 'updateConversionValueAsync', 

// the new conversion value will be determined according to the total amount of revenue.

// Note:

// 1. Call this method before calling 'updateConversionValueAsync' to 

// make sure that revenue is updated.

// 2. In case of retrying an event, avoid calling this method

// so the same revenue will not count twice.

+ (void)updateRevenue:(double)amount andCurrency:(NSString*)currency;

// Gets the current conversion value (nil if none)

+ (NSNumber *)getConversionValue;

 @end

SKAdNetworkインターフェースの実装

//  SKANSnippet.m


#import "SKANSnippet.h"
#import <StoreKit/SKAdNetwork.h>

#define SESSION_EVENT_NAME @”__SESSION__” 
#define SINGULAR_API_URL @"https://sdk-api-v1.singular.net/api/v1/conversion_value"

// Keys for UserDefaults storage

#define CONVERSION_VALUE_KEY @"skan_conversion_value"
#define FIRST_SKAN_CALL_TIMESTAMP @"skan_first_call_to_skadnetwork_timestamp"
#define LAST_SKAN_CALL_TIMESTAMP @"skan_last_call_to_skadnetwork_timestamp"
#define TOTAL_REVENUE_BY_CURRENCY_KEY @"skan_total_revenue_by_currency"
#define SECONDS_PER_DAY 86400

@implementation
SKANSnippet

+ (void)registerAppForAdNetworkAttribution {
    if ([SKANSnippet getFirstSkanCallTimestamp] != 0) {
        return;
    }

    if (@available(iOS 11.3, *) ) {
        [SKAdNetwork registerAppForAdNetworkAttribution];
        [SKANSnippet setFirstSkanCallTimestamp];
        [SKANSnippet setLastSkanCallTimestamp];
    }
}

+ (void)updateConversionValueAsync:(void(^)(int, NSError*))handler {
    [SKANSnippet updateConversionValueAsync:SESSION_EVENT_NAME withCompletionHandler:handler];
}

+ (void)updateConversionValueAsync:(NSString*)eventName withCompletionHandler:(void(^)(int, NSError*))handler {
    if (@available(iOS 14, *)) {
        if ([SKANSnippet isSkanUpdateWindowOver]) {
            return;
        }
        
        [SKANSnippet getConversionValueFromServer:eventName withCompletionHandler:handler];
    }
}

+ (void)updateRevenue:(double)amount andCurrency:(NSString*)currency {
    if (@available(iOS 14, *)) {
        
        // Update total revenues

        if (amount != 0 && currency) {
            NSMutableDictionary* revenues = [[SKANSnippet getTotalRevenue] mutableCopy];
            NSNumber* currentRevenue = 0;
            
            if ([revenues objectForKey:currency]) {
                currentRevenue = [revenues objectForKey:currency];
            }
            
            currentRevenue = @([currentRevenue floatValue] + amount);
            [revenues setObject:currentRevenue forKey:currency];
            
            [SKANSnippet setTotalRevenue:revenues];
        }
    }
}

+ (NSNumber *)getConversionValue {
    NSUserDefaults* userDefaults = [NSUserDefaults standardUserDefaults];
    if (![userDefaults objectForKey:CONVERSION_VALUE_KEY]) {
        return nil;
    }
    return @([userDefaults integerForKey:CONVERSION_VALUE_KEY]);
}

+ (void)getConversionValueFromServer:(NSString*)eventName withCompletionHandler:(void(^)(int, NSError*))handler {
    if (!lockObject) {
        lockObject = [NSLock new];
    }
    
    // Making the lock async so it will not freeze the calling thread

    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^(void) {
        
        [lockObject lock];
        
        NSURLComponents *components = [NSURLComponents componentsWithString:SINGULAR_API_URL];
        
        NSString* bundleIdentifier = @"";
        
        components.queryItems = @[
            [NSURLQueryItem queryItemWithName:@"a" value:@""],
            [NSURLQueryItem queryItemWithName:@"i" value:bundleIdentifier],
            [NSURLQueryItem queryItemWithName:@"app_v" value:@""],
            [NSURLQueryItem queryItemWithName:@"n" value:eventName],
            [NSURLQueryItem queryItemWithName:@"p" value:@"iOS"],
            [NSURLQueryItem queryItemWithName:@"idfv" value:@""],
            [NSURLQueryItem queryItemWithName:@"idfa" value:@""],
            [NSURLQueryItem queryItemWithName:@"conversion_value" 
 value:[[SKANSnippet getConversionValue] stringValue]],
            [NSURLQueryItem queryItemWithName:@"total_revenue_by_currency" 
 value:[SKANSnippet dictionaryToJsonString:[SKANSnippet getTotalRevenue]]],
            [NSURLQueryItem queryItemWithName:@"first_call_to_skadnetwork_timestamp" 
 value:[NSString stringWithFormat:@"%ld", [SKANSnippet getFirstSkanCallTimestamp]]],
            [NSURLQueryItem queryItemWithName:@"last_call_to_skadnetwork_timestamp" 
 value:[NSString stringWithFormat:@"%ld", [SKANSnippet getLastSkanCallTimestamp]]],
        ];
        
        [[[NSURLSession sharedSession] dataTaskWithURL:components.URL
                                     completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
            if (error) {
                [lockObject unlock];
                if (handler) {
                    handler(-1, error);
                }
                return;
            }
            
            NSDictionary* parsedResponse = [SKANSnippet jsonDataToDictionary:data];
            
            if (!parsedResponse) {
                [lockObject unlock];
                if (handler) {
                    handler(-1, [NSError errorWithDomain:bundleIdentifier
                                                    code:0
                                                userInfo:@{NSLocalizedDescriptionKey:@"Failed parsing server response"}]);
                }
                return;
            }
            
            NSNumber *conversionValue = [parsedResponse objectForKey:@"conversion_value"];
            
            if (!conversionValue) {
                [lockObject unlock];
                NSString *status = [parsedResponse objectForKey:@"status"];
                
                if (!status || ![status isEqualToString:@"ok"]) {
                    if (handler) {
                    		NSString *reason = [parsedResponse objectForKey:@"reason"];
                        if (!reason) {
                            reason = @"Got error from server";
                        }
                        
                        handler(-1, [NSError errorWithDomain:bundleIdentifier
                                                        code:0
                                                    userInfo:@{NSLocalizedDescriptionKey:reason}]);
                    }
                }
                return;
            }
            
            NSNumber* currentValue = [SKANSnippet getConversionValue];
            if ([conversionValue intValue] <= [currentValue intValue]) {
                [lockObject unlock];
                return;
            }
            
            [SKANSnippet setConversionValue:[conversionValue intValue]];
            [SKANSnippet setLastSkanCallTimestamp];
            
            if (![SKANSnippet getFirstSkanCallTimestamp]) {
                [SKANSnippet setFirstSkanCallTimestamp];
            }
            
            [lockObject unlock];
            
            if (handler) {
                handler([conversionValue intValue], error);
            }
        }] resume];
    });
}

+ (BOOL)isSkanUpdateWindowOver {        
    NSInteger timeDiff = [SKANSnippet getCurrentUnixTimestamp] - 
      [SKANSnippet getLastSkanCallTimestamp];
    return SECONDS_PER_DAY <= timeDiff;
}

SKAdNetwork メタデータとユーティリティメソッド

このセクションでは、上記の実装で使用されるユーティリティの実装を担当する。これらのユーティリティは、次の変換値を計算するために必要なメタデータを維持・保存するために重要であることに注意

以下のユーティリティが実装されています:

UserDefaultsへの値の保存(および値の取得):

  • 基礎となる SKAdNetwork API への最初の呼び出しタイムスタンプ。
  • 基礎となる SKAdNetwork API への最終呼び出しタイムスタンプ
  • 通貨別収益合計

異なる表現間の値の変換

  • 辞書からJSON文字列
  • JSONから辞書へ
+ (NSInteger)getFirstSkanCallTimestamp {
    NSUserDefaults* userDefaults = [NSUserDefaults standardUserDefaults];
    return [userDefaults integerForKey:FIRST_SKAN_CALL_TIMESTAMP];
}

+ (NSInteger)getLastSkanCallTimestamp {
    NSUserDefaults* userDefaults = [NSUserDefaults standardUserDefaults];
    return [userDefaults integerForKey:LAST_SKAN_CALL_TIMESTAMP];
}

+ (NSDictionary*)getTotalRevenue {
    NSUserDefaults* userDefaults = [NSUserDefaults standardUserDefaults];
    if (![userDefaults objectForKey:TOTAL_REVENUE_BY_CURRENCY_KEY]) {
        return [NSDictionary new];
    }
    return [userDefaults objectForKey:TOTAL_REVENUE_BY_CURRENCY_KEY];
}

+ (NSInteger)getCurrentUnixTimestamp {
    return [[NSDate date]timeIntervalSince1970];
}

+ (void)setConversionValue:(int)value {
    if (@available(iOS 14.0, *)) {
        if (value <= [[SKANSnippet getConversionValue] intValue]) {
            return;
        }
        
        [SKAdNetwork updateConversionValue:value];
        
        NSUserDefaults* userDefaults = [NSUserDefaults standardUserDefaults];
        [userDefaults setInteger:value forKey:CONVERSION_VALUE_KEY];
        [userDefaults synchronize];
    }
}

+ (void)setFirstSkanCallTimestamp {
    NSUserDefaults* userDefaults = [NSUserDefaults standardUserDefaults];
    [userDefaults setInteger:[SKANSnippet getCurrentUnixTimestamp] 
      forKey:FIRST_SKAN_CALL_TIMESTAMP];
    [userDefaults synchronize];
}

+ (void)setLastSkanCallTimestamp {
    NSUserDefaults* userDefaults = [NSUserDefaults standardUserDefaults];
    [userDefaults setInteger:[SKANSnippet getCurrentUnixTimestamp] 
      forKey:LAST_SKAN_CALL_TIMESTAMP];
    [userDefaults synchronize];
}

+ (void)setTotalRevenue:(NSDictionary *)values {
    NSUserDefaults* userDefaults = [NSUserDefaults standardUserDefaults];
    [userDefaults setObject:values forKey:TOTAL_REVENUE_BY_CURRENCY_KEY];
    [userDefaults synchronize];
}

+ (NSString*)dictionaryToJSONString:(NSDictionary*)dictionary {
    if (!dictionary || [dictionary count] == 0){
        return @"{}";
    }
    
    NSError *error;
    NSData *JSONData = [NSJSONSerialization dataWithJSONObject:dictionary
      options:0
      error:&error];
    if (error || !JSONData) {
        return @"{}";
    }
    
    return [[NSString alloc] initWithData:JSONData 
      encoding:NSUTF8StringEncoding];
}

+ (NSDictionary*)JSONDataToDictionary:(NSData*)JSONData {
    if (!JSONData) {
        return nil;
    }

    NSError * error;
    NSDictionary * parsedData = [NSJSONSerialization 
      JSONObjectWithData:JSONData
      options:kNilOptions error:&error];
      
    if (error || !parsedData) {
       return nil;
    }
    return parsedData;
}

@end

S2S統合アップデート - 実装(オプション)

以下のSKAdNetworkメタデータで、S2S統合を更新してください(このメタデータは、SKAdNetwork実装検証のため、Singularに報告される全てのセッション&イベントで転送される必要があります):

  • skan_conversion_value - 最新のコンバージョン値。
  • skan_first_call_timestamp - 基礎となるSKAdNetwork APIへの最初のコールのUnixタイムスタンプ。
  • skan_last_call_timestamp - 基礎となる SKAdNetwork API への最新の呼び出しの Unix タイムスタンプ。

以下のコード・スニペットは、これらの値を抽出する方法を示している:

NSDictionary *values = @{
    @"skan_conversion_value":
      [[SKANSnippet getConversionValue] stringValue]],
    @"skan_first_call_timestamp": 
      [NSString stringWithFormat:@"%ld", [SKANSnippet getFirstSkanCallTimestamp]],
    @"skan_last_call_timestamp": 
      [NSString stringWithFormat:@"%ld", [SKANSnippet getLastSkanCallTimestamp]]
};

これらのパラメータをサーバー側に送信すると、サーバー間APIエンドポイントを介して転送することができます。詳細については、server-to-server APIリファレンスでこれらのパラメータを検索してください。

アプリのライフサイクルフローの例

// On app launch

[SKANSnippet registerAppForAdNetworkAttribution];

// After each session is handled

[SKANSnippet updateConversionValueAsync:handler];

// After handling non-revenue events

[SKANSnippet updateConversionValueAsync:@"event_name" 
  withCompletionHandler:handler];

// After handling revenue events

[SKANSnippet updateRevenue:15.53 andCurrency:@"KRW"];
[SKANSnippet updateConversionValueAsync:@"revenue_event_name" withCompletionHandler:handler];

コード変更履歴

  • 1 2020年10月
    • 修正 [IMPORTANT]:`getConversionValueFromServer` のロックメカニズムが正しく開始されていなかった
    • 改善: サーバのレスポンスから失敗理由を返すようにした。
  • 2020/09/23
    • 修正 [IMPORTANT]:`setConversionValue` が `updateConversionValue` を呼び出すようになりました。
    • 改善: サーバから最新の変換値を取得する際のエラー処理
  • 2020/09/15
    • 重要]を変更しました: 変換値が設定されていない場合、`getConversionValue` が返すデフォルト値は null ではなく 0 になります。
    • 改善されました:収益の報告と処理
    • 改善されました:次のコンバージョン値の非同期取得

実装のテスト

コンバージョン管理

アプリのフローをシミュレートし、実装をテストするには、コンバージョン管理の本番エンドポイント(SINGULAR_API_URL)を次のテストエンドポイントに置き換えます。

推奨されるテストフロー

  • アプリ内で3つの異なるイベントを発生させます。
    • すべてが正しく実装されている場合
      • 各イベントの後、updateConversionValueAsyncが呼び出されるはずです(デフォルトの変換値が0に初期化されていることを確認してください)。
      • テスト・エンドポイントは、アプリから現在の変換値を受け取り、次の値を返します。
  • 返された変換値をログに記録して、すべてが期待通りに動作していることを確認します。
    • テストエンドポイントは0-2の間の値を返すように実装されています。
    • 従って、3回目の呼び出しの後は空の文字列を返し、最後の値が最終的なSKAdNetworkの変換値として使用される。