アプリに「気になることを書いてもらうと、AI が整理して返す」という小さな入力欄を足したときのことです。実装そのものは半日で終わりました。手が止まったのは、その次でした。Play Console の「データの安全性」を開いて、この入力欄のぶんをどこに書けばよいのか、しばらく画面を眺めていたのです。
自分のサーバーには保存していません。外部のモデルに投げて、返ってきた文章を表示して、それで終わりです。ならば「収集していない」でよいのか——最初のうち、私はそう考えておりました。取り違えに気づいたのは、フォームを三度目に開いた日でした。
結論から書きますと、それでは足りませんでした。フォームの区分は、私が保存するかどうかではなく、受け取った側がどう扱うかで決まっていたのです。個人開発でいくつかのアプリを配信しておりますが、この理解にたどり着くまでに、フォームと実際の送信内容がずれたまま何度か更新を重ねてしまいました。同じ場所で立ち止まる方のために、判断の順番を書き残します。
「共有」かどうかは、受け取った側の扱いで決まります
Play の用語では、端末の外へデータが出ることを「収集」、第三者へ渡ることを「共有」と呼び分けます。ここで誤解しやすいのは、共有の判定基準です。
判定するのは、送信の事実だけではありません。渡した先がそのデータを自分たちの目的で使うか 、そしてどれだけ保持するか が効いてきます。広告のターゲティング、アプリをまたいだプロファイリング、ベンチマーク——受け取り手がこうした用途に回すなら、それは共有です。
外部の AI サービスは、ここで判断が割れます。API 経由の利用では学習に使わないと明示している事業者が多い一方、不正利用の監視を目的に一定期間ログを保持する運用は珍しくありません。学習に使わないことと、保持しないことは別の話です。
送る判断は自分のコードが決めますが、申告の区分は相手の保持期間が決めます。 私はこの一文を、送信先を増やすたびに読み返すようにしております。
さらに厄介なのは、自分が書いた fetch 以外にも送信経路がある点です。広告 SDK もクラッシュ収集も、それぞれの判断で外へ出しています。アプリに入っている SDK が収集・共有するなら、それも申告に含める必要があります。SDK 側の連鎖まで洗う手順はRork 生成 Expo アプリの Privacy Manifest を SDK 連鎖まで監査する にまとめておりますので、あわせてご覧ください。
一時処理は「書かなくてよい」ではありません
もう一つ、私が取り違えていた点です。
Play には一時処理(ephemeral processing)という考え方があります。端末外へ出したデータを、メモリ上にだけ置き、その場の要求を処理するのに必要な時間を超えて保持しない場合が該当します。天気アプリが予報を取るために位置情報を渡す、といった例が挙げられています。
ここが肝心なのですが、一時処理に当たるデータもフォームでは回答します 。基準を満たしたときに起きるのは、ストアの公開ページにその項目が表示されなくなる、ということです。回答を省いてよいという意味ではありません。
「一時処理だから書かなくていい」と読んで空欄のまま出すと、実際の挙動と申告が食い違います。しかもこの食い違いは、審査で指摘されるまで自分では気づけません。私は一度、更新のたびに同じ空欄を素通りさせておりました。
そして一時処理の条件は、実装側の都合で簡単に外れます。応答を検証したくてリクエストとレスポンスをログに落とした瞬間、保持しないという前提が崩れます。デバッグのために足した一行が、区分を変えてしまうのです。
外へ出しているものを、一箇所を通してから送る
棚卸しを毎回やり直さずに済ませるには、送信口を絞るのが早道でした。画面のあちこちから直接 fetch を書かず、宛先ごとの性質を型で持たせた関数を一つだけ通します。
// src/net/outbound.ts
// 外部へ出す通信は、この関数だけを経由させます。
// destination の宣言が、そのままデータ安全性フォームの下書きになります。
export type Retention = "none" | "shortTerm" | "persisted" ;
export type Destination = {
id : string ; // 申告時に人が読む名前
host : string ; // 実際の送信先
retention : Retention ; // 相手がどれだけ持つか(契約・ドキュメントで確認した値)
usedByThirdParty : boolean ; // 相手が自分たちの目的で使うか
};
export const DESTINATIONS = {
assistant: {
id: "文章整理アシスタント" ,
host: "api.example-model.com" ,
retention: "shortTerm" , // 不正利用監視のため一定期間保持されるとの記載あり
usedByThirdParty: false ,
},
} satisfies Record < string , Destination >;
type SendOptions = {
destination : Destination ;
body : unknown ;
signal ?: AbortSignal ;
};
export async function sendOutbound ({ destination , body , signal } : SendOptions ) {
const startedAt = Date. now ();
const requestId = Math. random (). toString ( 36 ). slice ( 2 , 10 );
try {
const res = await fetch ( `https://${ destination . host }/v1/complete` , {
method: "POST" ,
headers: { "Content-Type" : "application/json" },
body: JSON . stringify (body),
signal,
});
if ( ! res.ok) throw new Error ( `upstream ${ res . status }` );
return await res. json ();
} finally {
// 本文はログに残しません。残すのは追跡に要る最小限だけです。
console. log (
`[outbound] id=${ requestId } to=${ destination . id } ms=${ Date . now () - startedAt }`
);
}
}
この形にしてから、申告の下書きが楽になりました。DESTINATIONS を上から読めば、宛先と保持と第三者利用がそのまま並んでいるからです。宛先を増やすときに retention を埋めなければ型が通らないので、確認しないまま出荷することもなくなりました。
finally の中で本文を出していない点が、地味ですが効きます。ここに body を書いてしまうと、端末のログにユーザーの文章が残ります。一時処理という前提は、相手側だけでなく自分の側でも守る必要があるのです。
落とす処理の順序を間違えると、消したつもりが半分残ります
送る前に、要らないものは落とします。ここで実際につまずいた話を書きます。
メールアドレス、電話番号、長い数字列を伏せる関数を書き、手元で通してみました。試した入力と、返ってきた結果です。
先週の更新から、保存ボタンを押すと画面が止まります。
連絡先は taro.example@example.com、電話は 090-1234-5678 です。
レシート番号 4901234567890 でも同じ症状が出ました。
端末は iPhone 15 Pro、アプリは v2.1.0 です。
メール → 電話 → 長い数字列、という順で置き換えたときの出力がこちらです。
連絡先は [email]、電話は [phone] です。
レシート番号 49[phone] でも同じ症状が出ました。
レシート番号が 49[phone] になりました。日本の電話番号にあたる正規表現が 0123456789 の部分を先に食ってしまい、頭の 49 だけが原文のまま残ったのです。伏せたつもりで、桁の一部を送っておりました。
原因は単純で、順序でした。連続した長い数字列を先に確定させておけば、電話番号のパターンに割り込まれません。並べ替えたものがこちらです。
// src/net/redact.ts
const EMAIL = / [A-Za-z0-9._%+-] + @ [A-Za-z0-9.-] + \. [A-Za-z] {2,} / g ;
const LONG_DIGITS = / \d {10,} / g ; // 注文番号・会員番号。ハイフン無しの連番
const PHONE_JP = /(?: \+ 81 [-\s] ?| 0) \d {1,4} [-\s] ? \d {1,4} [-\s] ? \d {3,4} / g ;
export function redact ( input : string ) {
const hits = { email: 0 , longDigits: 0 , phone: 0 };
let out = input. replace ( EMAIL , () => { hits.email ++ ; return "[email]" ; });
out = out. replace ( LONG_DIGITS , () => { hits.longDigits ++ ; return "[number]" ; }); // 先に確定
out = out. replace ( PHONE_JP , () => { hits.phone ++ ; return "[phone]" ; });
return { text: out, hits, before: [ ... input]. length , after: [ ... out]. length };
}
同じ入力を通し直した結果です。
連絡先は [email]、電話は [phone] です。
レシート番号 [number] でも同じ症状が出ました。
端末は iPhone 15 Pro、アプリは v2.1.0 です。
{"beforeChars":146,"afterChars":118,"email":1,"longDigits":1,"phone":1}
146 文字が 118 文字になりました。19% ほど短くなった計算です。内訳は メール 1 件・長い数字列 1 件・電話 1 件でした。件数を返しておくと、後から「この画面では平均して何件伏せているか」を眺められます。
消しすぎていないかも確かめました。バージョン番号 v2.1.0、日付 2026-09-07、iOS 27、0.5秒、エラーコード 0x8badf00d、解像度 1080x1920 は、いずれも素通りしました。不具合報告の役に立つ情報は残っています。ここを確かめずに範囲を広げていたら、送られてくる報告そのものが読めなくなっていたかもしれません。
伏せる処理は、書いた時点では正しく見えます。実際の文章を通して、前後を並べて読むまでは判断できないのだと思い知りました。
開発ビルドで、送信先のホストを数える
宛先の宣言を作っても、そこに載っていない通信が残っていないかは別問題です。SDK が出しているぶんは、こちらのコードには現れません。
開発ビルドのときだけ fetch をくるみ、ホスト名を集めています。
// src/dev/outboundAudit.ts
// __DEV__ のときだけ有効化します。本番ビルドには入れません。
const seen = new Map < string , number >();
export function installOutboundAudit () {
if ( ! __DEV__) return ;
const original = global.fetch;
global. fetch = async ( input : RequestInfo | URL , init ?: RequestInit ) => {
const url = typeof input === "string" ? input : input. toString ();
try {
const host = new URL (url).host;
seen. set (host, (seen. get (host) ?? 0 ) + 1 );
} catch {
// 相対 URL 等は数えません
}
return original (input, init);
};
}
export function dumpOutboundHosts () {
const rows = [ ... seen. entries ()]. sort (( a , b ) => b[ 1 ] - a[ 1 ]);
console. log ( "[outbound-audit] " + JSON . stringify (Object. fromEntries (rows)));
}
アプリを一周操作してから dumpOutboundHosts() を呼び、出てきたホストを宛先の宣言と突き合わせます。fetch を経由しないネイティブ側の通信までは拾えませんので、これは網羅ではなく取っかかりです。それでも、宣言に無いホストが一つ出てくれば、そこから先は SDK のドキュメントを読む作業に移れます。
同意の取得順序まで含めた起動時の設計は、Rork × AdMob UMP × ATT 同意管理の本番実装ガイド で扱っております。
申告の区分を決める表
手元で使っている対応表です。宛先ごとに、この三列を埋めてから区分を決めています。
送るもの 相手の扱い フォームでの区分 公開ページ
ユーザーが書いた文章 メモリ上のみ・保持なし 収集あり/一時処理に該当 表示されません
ユーザーが書いた文章 監視目的で一定期間ログ保持 収集あり/一時処理に該当せず 表示されます
ユーザーが書いた文章 相手が自社の目的でも利用 収集あり+共有あり 表示されます
広告 ID(広告 SDK 経由) ターゲティングに利用 共有あり(デバイス ID) 表示されます
クラッシュログ 収集事業者が保持 収集あり(アプリのパフォーマンス) 表示されます
一行目と二行目の違いが、この記事でいちばんお伝えしたかったところです。送っているものは同じ文章なのに、相手の保持期間だけで公開ページの見え方が変わります。だから確認するべきは自分のコードではなく、相手の利用規約とデータ保持のドキュメントなのです。
保持期間が書かれていない、あるいは問い合わせても確定しない場合は、保持されている前提で申告しております。控えめに書いて後から差し戻されるより、多めに申告して後から減らすほうが、更新の回転が止まりません。
提出前に見返している四つの問い
フォームを保存する前に、この四つを自分に問うています。
宛先の宣言に載っていないホストが、監査ログに出ていないか
送信前に落としているものを、実際の文章で前後比較したか
デバッグ用のログに、本文がそのまま残っていないか
相手の保持期間を、二次情報ではなく提供元のドキュメントで確かめたか
三番目でよく引っかかります。不具合を追っているあいだに足したログが、そのまま残ることがあるのです。リリース前に console.log を洗う作業を、私は申告の見直しとセットにしております。
同じコードベースから複数のアプリを出している場合は、アプリごとに送信先が違うのに、フォームだけ前回の内容をそのまま複製してしまいがちです。この落とし穴については同じコードベースから出す2本目のアプリに、固有価値を宣言する1枚を置いています でも触れております。
次にやること
まずは、いま配信しているアプリを一周操作しながら、開発ビルドで送信先のホストを一覧にしてみてください。宛先の宣言も、伏せる処理も、そのあとで足せます。順番を逆にすると、手元の理解ではなく想像で書いた宣言ができあがってしまいます。
外部の AI を組み込むこと自体は、いまや半日の作業です。時間がかかるのは、送っているものを自分の言葉で説明できるようにする側でした。落とし穴は宛先の数ではなく、宛先ごとに条件が違うことにあるのだと思います。私もまだ棚卸しの途中ですが、宛先を一箇所に集めておいてよかったと感じております。お読みいただきありがとうございました。