İçeriğe atla

2026-05-28

Compile-Time PII Koruması: Logger, Metrics ve Span için Tek Bir Zod Brand

Tek bir PII branded type'ı observability API imzalarınıza yerleştirin; TypeScript hassas alanları runtime redactor görmeden, çağrı yerinde reddetsin.

Structured loglar PII’yi birbirine çok benzeyen üç çağrı yerinden sızdırır: logger.info({ user }), metrics.increment("login", { email }) ve tracer.startActiveSpan("auth", { attributes: { ip } }). Çoğu ekip bunu yakalamak için Pino redact.paths ya da cloud tarafında bir scrubber ekler. O savunmalar son hattır ve liste tabanlıdır; şemalar değiştikçe sessizce kayarlar. Effect-TS aynı problemi runtime’da Redacted<A> ve değer yerine <redacted> basan bir Logger ile çözüyor. Pino, problemin bir dilimini compile-time’da LogFnFields ile çözüyor; belirli anahtarları never üzerinden yasaklıyor. Rust’ın secrecy crate’i ise wrapper üzerinde Display’i hiç implement etmeyerek çözüyor. Aynı compile-time fikre düz Zod ve TypeScript ile iki paralel uygulamadan ulaşılabiliyor. Biri Zod brand: hafif ve yapısal. Diğeri wrapper sınıfı: daha ağır, ama runtime kapsamı da var. İkisi de observability sink’inde PII’yi reddediyor. Hangisinin doğru olduğu, kod tabanınızın ne kadar müdahale kaldırabildiğine bağlı.

Pattern’in felsefesi

Pino’nun kendi redaction rehberi özünde şunu söylüyor: redact path’leri kullanıcı girdisinden gelmemeli ve daha güvenli olan, hassas veriyi log payload’ına en başta koymamaktır. Datadog Sensitive Data Scanner ve AWS CloudWatch Logs Data Protection gibi cloud tarafı scrubber’lar da aynı şekilde çerçevelenir: destek hattı olarak yararlı, birincil kontrol olarak tehlikeli.

Compile-time önleme bu felsefenin sıkı okuması. Eğer PII tipli bir değer söz dizimsel olarak logger.info, metrics.increment ya da span’a ulaşamıyorsa, runtime katmanına redact edecek hiçbir şey kalmıyor. Maliyeti tek bir branded type, tek bir conditional helper ve birkaç daha sıkı API imzası.

Hassas her şey için tek brand

İlk karar şu: her alanı ayrı ayrı sarmak mı (Email, IBAN, Phone), yoksa hepsinin üzerine tek bir PII brand’i paylaştırmak mı? İçgüdü per-field wrapper’lara yöneltir; domain kodunda daha okunaklı dururlar. Ama değeri loglardan uzak tutmak için bu çözünürlük gereksiz. Logger, ihlal eden alanın email mi yoksa IBAN mı olduğunu umursamaz. Değerin hassas olup olmadığını umursar.

Tek bir brand paylaşmak, logger tarafındaki type guard’ı bir union’dan tek bir kontrole indirger ve her yeni hassas alanı observability katmanına öğretme zorunluluğunu ortadan kaldırır. Brand bir contracts paketinde yaşar; böylece her servis aynı nominal kimliği import eder.

// contracts/pii.ts
import { z } from "zod";

// Zod'un kendi brand kodlamasını kullanıyoruz. `string & z.$brand<"PII">`
// Zod'un `.brand<"PII">()` çağrısının infer ettiği tip; böylece API yüzeyi
// ile şema katmanı tam olarak aynı nominal kimlik üzerinde anlaşıyor.
export type PII = string & z.$brand<"PII">;

// Helper: string üreten herhangi bir Zod şemasına PII brand'ini basıyor.
// `T extends z.ZodType<string>` bilinçli bir tercih; Zod v4'te `z.email()`,
// `z.iso.date()` ve `z.uuid()` artık `z.ZodString` alt sınıfı değil.
export const pii = <T extends z.ZodType<string>>(s: T) => s.brand<"PII">();

// Per-key reddetme guard'ı. Brand makineriyle birlikte yaşıyor çünkü brand
// mekanizmasının parçası, observability katmanının değil.
// Union'lar üzerinde dağılım: V çıplak bir tip parametresi olduğu için
// RejectPII<PII | undefined> = (PII -> never) | (undefined -> undefined) = undefined.
type RejectPII<V> = V extends z.$brand<"PII"> ? never : V;
export type SafeCtx<T> = { [K in keyof T]: RejectPII<T[K]> };

Bir customer-onboarding akışının şemaları şuna benziyor. Alanlar düzenlenmiş herhangi bir sektörde ortak: email, IBAN, ulusal kimlik, ISO formatında doğum tarihi, adres.

// contracts/customer.ts
import { z } from "zod";
import { pii } from "./pii";

export const Email = pii(z.email());
// Sadece şekil kontrolü, tam doğrulama değil. Üretim kodunda bunu mod-97
// checksum ve ülkeye özel uzunluk tablolarıyla birlikte kullanmak gerekir.
export const IBAN = pii(z.string().regex(/^[A-Z]{2}\d{2}[A-Z0-9]{1,30}$/));
export const MobileNumber = pii(z.string().regex(/^\+?[1-9]\d{1,14}$/));
export const DateOfBirth = pii(z.iso.date());
export const Address = pii(z.string().min(1).max(500));
// Sadece şekil kontrolü; üretim kodu, yargı yetkisinin ulusal kimlik
// formatını kullanmalı.
export const NationalId = pii(z.string().regex(/^\d{8,12}$/));

export const Customer = z.object({
  id: z.uuid(),
  email: Email,
  iban: IBAN,
  mobileNumber: MobileNumber,
  dateOfBirth: DateOfBirth,
  address: Address,
  nationalId: NationalId,
  marketingOptIn: z.boolean(),
});

export type Customer = z.infer<typeof Customer>;

Customer.parse(raw) çağrısından sonra customer.email runtime’da hâlâ yapısal olarak bir string; brand yalnızca tip sisteminde var. Değer veritabanı yazımlarına, dış API çağrılarına ve imza üretimine olduğu gibi akıyor. Brand’in asıl çekiciliği bu: kod tabanının geri kalanına minimum müdahale. Sızıntıyı durduran şey, observability sink’indeki compile-time kapısı.

Observability API’sinde PII’yi reddet

Brand tek başına API sınırında faydalı bir şey yapmaz. Asıl kazanç, logger’a, metrics istemcisine ve tracer’a yapılandırılmış context kabul ettiklerini, ama yalnızca PII olmayan değerlerle kabul ettiklerini öğretmekten gelir. Brand makineri tamamen contracts paketinde yaşıyor; observability primitifleri sadece SafeCtx’i import edip context argümanlarına uyguluyor.

// core/observability.ts
import pino from "pino";
import { StatsD } from "hot-shots";
import { trace, type AttributeValue } from "@opentelemetry/api";
import type { SafeCtx } from "@org/contracts";

const baseLogger = pino();
const statsd = new StatsD({ prefix: "app." });
const tracer = trace.getTracer("app");

export function makeLogger(name: string) {
  return {
    info: <T extends Record<string, unknown>>(msg: string, ctx?: SafeCtx<T>) =>
      baseLogger.info({ ...ctx, logger: name }, msg),
    warn: <T extends Record<string, unknown>>(msg: string, ctx?: SafeCtx<T>) =>
      baseLogger.warn({ ...ctx, logger: name }, msg),
    error: <T extends Record<string, unknown>>(msg: string, err?: Error, ctx?: SafeCtx<T>) =>
      baseLogger.error({ ...ctx, err, logger: name }, msg),
  };
}

export function makeMetrics(prefix: string) {
  return {
    increment: <T extends Record<string, string>>(name: string, tags?: SafeCtx<T>) =>
      statsd.increment(`${prefix}.${name}`, 1, tags),
    histogram: <T extends Record<string, string>>(
      name: string,
      value: number,
      tags?: SafeCtx<T>,
    ) => statsd.histogram(`${prefix}.${name}`, value, tags),
  };
}

export async function span<T, A extends Record<string, AttributeValue | undefined>>(
  name: string,
  attrs: SafeCtx<A>,
  fn: () => T | Promise<T>,
): Promise<T> {
  return tracer.startActiveSpan(name, { attributes: attrs }, async (s) => {
    try {
      return await fn();
    } finally {
      s.end();
    }
  });
}

Tüm pattern burada. Üç primitive, her property’yi gezen ve PII tipli slot’ları never’a yeniden yazan paylaşılan tek bir SafeCtx<T> mapped type. Argüman caller’ın literal T’si üzerinden generic; compiler’a her alanı tek tek inceleme şansı veren şey de bu. Bir caller logger’a tipi PII olan bir değer verdiğinde, o anahtarın beklenen tipi never’a çöker; literal’in PII değeri never’a atanamaz ve build kırılır.

span, callback senkron olsa bile Promise<T> döndürüyor; bu tracing için doğru ödünleşim, çünkü s.end() iş bittikten sonra çalışmalı ve span içindeki işlerin çoğu async oluyor. Alternatif, yaygın durumu kapsamayan bir senkron overload olurdu.

Bir onboarding handler’ında temsil edici bir çağrı yeri:

const logger = makeLogger("onboarding");
const metrics = makeMetrics("onboarding");

export async function onboard(customer: Customer) {
  logger.info("onboarding.start", {
    customerId: customer.id,                  // string, OK
    marketingOptIn: customer.marketingOptIn,  // boolean, OK
  });

  // Aşağıdaki iki satır derlenmez.
  logger.info("onboarding.start", { email: customer.email });
  // Type 'string & $brand<"PII">' is not assignable to type 'never'.

  metrics.increment("onboarding.requested", { iban: customer.iban });
  // Type 'string & $brand<"PII">' is not assignable to type 'never'.

  return span(
    "identity.verify",
    { customerId: customer.id },              // OK
    () => verifyIdentity(customer.nationalId),
  );
}

Compiler hataları doğrudan ve üretim kodunda açıklama gerektirmeden okunuyor. Reviewer satırı görüyor ve type system noktayı zaten koymuş oluyor.

Tip matematiği nasıl çalışıyor

SafeCtx<T> bir mapped type; her property’yi RejectPII<V>’ye delege ediyor. V conditional’ın içinde çıplak bir tip parametresi olduğu için, conditional union’lar üzerinde dağılıyor; opsiyonel PII (PII | undefined, PII | null) çağrı yerinde sessizce geçmek yerine işte bu sayede hata veriyor.

// Caller şunu yazıyor:
logger.info("login", { email: customer.email, userId: customer.id });

// 1. TS infer eder: T = { email: PII; userId: string }
// 2. SafeCtx<T> her anahtar için RejectPII üzerinden açılır:
//    email:  PII    extends z.$brand<"PII"> ? never : PII    -> never
//    userId: string extends z.$brand<"PII"> ? never : string -> string
// 3. Gerekli argüman tipi: { email: never; userId: string }
// 4. Gerçekten geçirilen argüman: { email: PII;  userId: string }
// 5. PII, never'a atanamaz -> `email` anahtarında hata
//    "Type 'string & $brand<\"PII\">' is not assignable to type 'never'."

Dağılım, çağrı yerinin opsiyonel bir PII alanı geçirdiğinde önem kazanıyor:

// Caller şunu yazıyor:
logger.info("test", { email: maybeEmail });
// burada maybeEmail: PII | undefined

// 1. TS infer eder: T = { email: PII | undefined }
// 2. SafeCtx<T> her anahtar için:
//    email: RejectPII<PII | undefined>
//         = (PII extends z.$brand<"PII"> ? never : PII)              // -> never
//         | (undefined extends z.$brand<"PII"> ? never : undefined)  // -> undefined
//         = undefined
// 3. Gerekli argüman tipi: { email: undefined }
// 4. Gerçekten geçirilen argüman: { email: PII | undefined }
// 5. PII | undefined, undefined'a atanamaz -> `email` üzerinde hata
//    "Type 'PII | undefined' is not assignable to type 'undefined'."

Akılda tutulması gereken zihinsel model genişlemenin kendisinden daha basit. SafeCtx, PII tipli her slot’u hiçbir şeyin dolduramayacağı bir slot’a yeniden yazıyor; sonra caller’a yine de doldurmasını söylüyor. Compiler reddetmeyi per-key yapıyor; bu yüzden hata mesajı, tüm-nesne uyumsuzluğunu suçlamak yerine ihlal eden property’yi adıyla anıyor.

Şema tanımından çağrı yerine kadar akış, iki sonucu olan tek bir yol. İki uygulama da, brand de wrapper sınıfı da, bu hattın aynı noktasına oturuyor.

Zod schemas: Email, IBAN, MobileNumber, DateOfBirth, Address, NationalId

PII branded type or wrapper class

SafeCtx T mapped type

makeLogger, makeMetrics, span signatures

call compiles: plain id, boolean, number

call rejected: PII value in context

Brand’in yakaladıkları

İki senaryo, brand-only versiyonunun önlediği günlük sızıntıların çoğunu kapsıyor.

Doğrudan alan geçirme. Bir geliştirici bir webhook handler’ını debug ediyor ve doğal forma uzanıyor: logger.info("received", { email: payload.email }). payload parse edilmiş bir Customer’dan geldiyse, payload.email PII ve satır derlenmiyor. Alan adını önceden bilmesi gereken bir Pino runtime config’i yok, senkron kalması gereken bir schema-to-paths walker yok.

Context object üzerinden propagate olan PII. Auth middleware’leri sıkça bir logger context çantası inşa eder ve bunu downstream handler’lara taşır. Biri o çantaya email ya da ipAddress eklediği anda, her downstream logger.info(msg, ctx) çağrısı derleme hatası veriyor. Sızıntı kaynağında bir kere düzeltiliyor, context’i tüketen her çağrı yerinde değil.

İkisi için de hata modu yüksek sesli ve yerel. Compile-time hata, ihlal eden property’yi ve dosyayı isimle söyler. Runtime izlemeye gerek kalmaz.

RejectPII<V> union’lar üzerinde dağıldığı için opsiyonel PII alanları da yakalanıyor. PII | undefined ve PII | null, union’un örtüsü altında sıyrılmak yerine çağrı yerinde hata veriyor; aynısı wrapper tarafında sınıf-instance union’ları için de geçerli.

Brand’in hâlâ kaçırdıkları

Brand yapısal: runtime’da bir PII değeri hâlâ sadece bir string. Tip sistemi adlandırılmış-anahtar geçirmelerini yakalıyor, ama üç escape hatch kalıyor.

Template literal

logger.info(`User ${customer.email} logged in`);

Template interpolation brand’i buharlaştırıyor. Ortaya çıkan string düz string ve message string’i ham email’i taşıyor. Compile-time gate burada tutunamıyor çünkü değer asla yapılandırılmış bir context property’si olarak görünmüyor. Branded değerlerin template interpolation’ına karşı lint kuralları yardımcı, ama bunun için topluluk kuralları olgun değil; küçük bir custom AST kuralı genellikle pratik cevap.

JSON.stringify

logger.info("payload", { body: JSON.stringify(customer) });

Branded bir string üzerinde JSON.stringify ham string’i döndürüyor. body alanı düz bir string ve brand serializer’dan sağ çıkmıyor. Burada en iyi alışkanlık, tüm payload’ı stringify etmek yerine ayrık alanları loglamak.

any cast

logger.info("ok", { email: customer.email as any });

Cast, PII’yi any’ye genişletiyor; any string | number | boolean’ı tatmin ediyor. Satır derleniyor. Bu pattern’in en açık şekilde yenildiği yol ve review’da en kolay yakalanan yol. Kaynak tipi branded type olan değerlerde as any’yi yasaklayan bir ESLint kuralı boşluğun çoğunu kapatıyor; tooling olmadan bile cast grep’lenebilir.

İç içe nesneler

SafeCtx<T> guard’ı yalnızca bir seviye derinliğe iniyor. PII’yi düz bir nesnenin içine gömen bir çağrı sessizce derleniyor:

logger.info("user.created", { user: customer });
//                            ^^^^^ bu `Customer`, PII değil; guard içine bakmıyor.

Mapped type’ı iç içe nesnelere recursive yapmak prensipte mümkün, ama köşe durumları var (diziler, sınıflar, branded nesneler). Pratikte kaçış kapısı yazım disiplini: parse edilmiş payload’ları log context’ine geçirmeyin. Ayrık alanları loglayın; SafeCtx<T> onları alan seviyesinde yakalar. Tip sisteminin ulaşamadığı durumlar için runtime redactor yedeği doğru cevap.

Bu escape hatch’lerin kabul edilebilir bir kalıntı risk olduğu kod tabanları için brand doğru tercih. Blast radius’u bu hatch’leri kabul edilemez kılan alanlar için aşağıdaki alternatif, daha ağır bir migration karşılığında, runtime hatch’lerini de kapatıyor.

Runtime kaçışlarını kapatmak: wrapper sınıfı

Wrapper sınıfı, blast radius’u runtime kaçışlarını kabul edilemez kılan alanlar için alternatif. Yapısal bir brand yerine PII gerçek bir sınıf oluyor; runtime metotları bir string’in bir nesneden çıkarken aldığı her standart yolu kesiyor. contracts/customer.ts içindeki şemalar değişmiyor; sadece contracts/pii.ts değişiyor.

// contracts/pii.ts — daha yüksek hassasiyetli alanlar için sınıf wrapper varyantı
import { z } from "zod";

const REDACTED = "[REDACTED]" as const;

export class PII {
  readonly #value: string;
  constructor(value: string) {
    this.#value = value;
  }
  toString(): string { return REDACTED; }
  toJSON(): string { return REDACTED; }
  [Symbol.toPrimitive](_hint: string): string { return REDACTED; }
  [Symbol.for("nodejs.util.inspect.custom")](): string { return REDACTED; }
  /** Açık opt-out: ham değeri döndürür. Yüksek sesli, yerel, gözden geçirilebilir. */
  reveal(): string { return this.#value; }
}

export const pii = <T extends z.ZodType<string>>(s: T) =>
  s.transform((v) => new PII(v));

// SafeCtx artık sınıf instance'ı üzerinden eşleşiyor. RejectPII'deki çıplak tip
// parametresi union'lar üzerinde dağılıyor; bu sayede `PII | undefined` doğru
// biçimde `undefined`'a çöküyor ve çağrı yeri PII dalında hata veriyor.
type RejectPII<V> = V extends PII ? never : V;
export type SafeCtx<T> = { [K in keyof T]: RejectPII<T[K]> };

core/observability.ts değişmiyor. SafeCtx’i @org/contracts’tan tam olarak öncekiyle aynı şekilde import ediyor; değişim tamamen contracts paketinin içinde gerçekleşiyor. Önce derlenen her çağrı yeri hâlâ derleniyor ve önce hata veren her çağrı yeri hâlâ hata veriyor. Değişen şey, tip sisteminden kaçan değerlere ne olduğu.

Ödünleşim gerçek ve dile getirilmeye değer. Brand ile customer.email runtime’da bir string ve db.user.update({ email: customer.email }) ya da mailer.send({ to: customer.email })’e olduğu gibi akıyor. Sınıf ile her meşru sınır artık customer.email.reveal() istiyor. Bu, olgun veritabanı, mail ve üçüncü parti entegrasyon koduna sahip her serviste önemsiz olmayan bir refaktör. Karşılığında template literal’ler, JSON.stringify, console.log ve util.inspect otomatik olarak [REDACTED] döndürüyor. Wrapper üzerindeki dört metot, bir string’in bir nesneden çıkarken aldığı her standart yolu kapsıyor.

Tek bir sağlık kontrolü izi runtime ayrışmasını görünür kılıyor:

info(`user ${customer.email} logged in`);
// Brand versiyonu: derlenir, mesaj ham email'i içerir. Brand kaybolmuştur.
// Sınıf versiyonu: derlenir, mesaj runtime'da "user [REDACTED] logged in"
//                  olur; çünkü wrapper'ın toString'i "[REDACTED]" döndürür.

İki uygulama arasında compile-time davranışı aynı. Runtime davranışı, brand’in yetersiz kaldığı tam noktalarda ayrışıyor. Sınıf tarafında üç kalıntı risk hâlâ duruyor: as any tip guard’ını yine de deviriyor, .reveal() sessiz bir kaçış kapısı olarak kötüye kullanılabiliyor ve SafeCtx<T> guard’ı hâlâ yalnızca bir seviye derinliğe iniyor. logger.info("user.created", { user: customer }) gibi bir çağrı yine derleniyor çünkü guard içteki Customer nesnesine recurse etmiyor. Runtime wrapper bunların bir kısmını yazım anında yakalıyor, ama yazım disiplini aynı: parse edilmiş payload’ları değil, ayrık alanları loglayın. Üçü de kod review’da yakalanabilir, ama wrapper var diye yok olmuyorlar.

Dört çağrı yeri üzerinden sağlık kontrolü

Pattern yazının geri kalanı için yük taşıyor; bu yüzden somut girdileri yeni imzalara karşı izlemek değer. 1’den 3’e kadar olan izler iki uygulama için aynı geçerli. 4 numarada yollar ayrılıyor.

// 1. Sadece operasyonel alanlar: ikisinde de derlenir.
logger.info("onboarding.start", {
  customerId: customer.id,                  // string
  marketingOptIn: customer.marketingOptIn,  // boolean
});

// 2. Tek bir PII alanı: ikisinde de `email` üzerinde hata.
//    Brand: Type 'string & $brand<"PII">' is not assignable to type 'never'.
//    Class: Type 'PII'                    is not assignable to type 'never'.
logger.info("onboarding.start", { email: customer.email });

// 3. Karışık payload: ikisinde de sadece `iban` üzerinde hata.
metrics.increment("onboarding.requested", {
  region: "eu-central",                     // string, OK
  iban: customer.iban,                      // PII, burada hata verir
});

// 4. Template literal üzerinden zorlanan PII — davranış uygulamaya bağlı.
//    Brand versiyonu: derlenir, mesaj ham email'i içerir. Brand kaybolmuştur.
//    Sınıf versiyonu: derlenir, mesaj runtime'da "user [REDACTED] logged in"
//                     olur; çünkü wrapper'ın toString'i "[REDACTED]" döndürür.
info(`user ${customer.email} logged in`);

Dördü de sezgiyle örtüşüyor: PII ya derlenmez ya da, sınıf versiyonunda, basılmaz. Compile-time gate yapılandırılmış-context durumlarını her iki uygulamada da isimle yakalıyor; wrapper buna ek olarak, tip sisteminin geçemediği template-literal durumunu yakalıyor.

Brand mi class mı: hangisi

İki uygulama aynı ödünleşim eğrisinin farklı noktalarında oturuyor. Hiçbiri evrensel olarak doğru değil.

BoyutBrand string & z.$brand<"PII">Wrapper sınıfı class PII { ... }
logger/metrics/span’de compile-time reddiEvetEvet
Runtime savunma (template literal, JSON.stringify, console.log)HayırEvet
Inferred tipstring (yapısal olarak uyumlu)PII instance
Migration maliyetiDüşük; DB yazımları, API çağrıları değişmezYüksek; her meşru sınırda .reveal()
Ne zaman seçilirYeni kod tabanı ya da düşük template-literal riski; minimum müdahaleEn yüksek hassasiyetli alanlar; ekip sınır refaktörünü kabul ediyor

Varsayılan tavsiye brand. Tanıtması ucuz, mevcut string-işleyen kodla yapısal olarak uyumlu ve reviewer’ların vaktini geçirdiği adlandırılmış-anahtar sınırlarında kazaen sızıntıların büyük çoğunluğunu yakalıyor. Observability sink’indeki compile-time kapısı ağır işi yapıyor. Kalan runtime hatch’leri (template literal, JSON.stringify, as any) lint kuralları ve PR review ile yakalanabilir.

Sınıf, blast radius’un refaktör maliyetini haklı çıkardığı küçük alan altkümesi için doğru cevap. Düzenlenmiş sektör bağlamında bu genellikle üç ila beş alan demek: ulusal kimlik, ham IBAN, isimle birlikte doğum tarihi ve tokenize edilmiş ama geri çevrilebilir herhangi bir kimlik. Bunları sınıfla sarın; geri kalanı brand’leyin.

Kaçınılması gereken şey, aynı contracts paketinde iki uygulamayı yan yana çalıştırmak. Tek bir repo’da iki PII kimliği her import yerinde belirsizlik üretiyor ve tüm pattern’in dayandığı “servis başına tek paylaşılan kimlik” özelliğini zayıflatıyor. Servis başına tek bir kimlik seçin. En yüksek hassasiyetli alanlar sınıf wrapper’ını haklı çıkarıyorsa, her yerde sınıfı kullanın; aksi halde her yerde brand’i kullanın.

Bu pattern’in ekosistemdeki yeri

Pattern boşlukta ortaya çıkmıyor. Aynı problemin komşu versiyonlarını çözen üç yayımlanmış yaklaşım var; bunları adlandırmak brand ve wrapper sınıfının üstüne ne kattığını netleştiriyor.

Effect-TS Redacted<A> wrapper sınıfının doğrudan kavramsal atası. Effect’in Redacted’ı da bir wrapper; özel toString ve inspect ile redact eden ve Effect’in Logger’ına bağlı. Yukarıdaki sınıf wrapper’ı bu fikrin framework-agnostik versiyonu: aynı redact eden metotlar, benimsenecek Effect runtime’ı yok, Pino, Winston ya da console kullanan herhangi bir kod tabanına düşüyor. SafeCtx<T> üzerinden compile-time guard, Effect’in vermediği bir şeyi ekliyor: çağrının kendisi, herhangi bir runtime redactor çalışmadan önce bir tip hatasına dönüşüyor.

Pino LogFnFields, v9.14.0 (Ekim 2025) ile geldi ve yukarıdaki brand pattern’inin dayandığı aynı never numarasını kullanıyor. Brand tabanlı SafeCtx bunun değer-tipi kuzeni: Pino alan adıyla yasaklıyor, brand değer tipiyle yasaklıyor. Alan-adıyla yasaklama, ihlal eden anahtarlar stabil ve sayılabilir olduğunda mükemmel iş çıkarıyor. Bir geliştirici { userKey: piiValue } yazdığı anda, üstelik alan-adı listesinin tanımadığı herhangi bir anahtar altında yazdığı anda yetersiz kalıyor; span attribute map’leri, metric tag çantaları ve yapılandırılmış error context nesneleri hepsi bu testten kalıyor. İki mekanizma temiz şekilde birleşiyor: bilinen hassas anahtar adları için LogFnFields’ı, geri kalan her şey için brand tabanlı guard’ı kullanın.

Rust’ın secrecy, sec ve safelog crate’leri aynı sonuca trait coherence üzerinden ulaşıyor. Secret<T>, Display ya da Debug implement etmiyor; dolayısıyla println!("{}", secret) derlenmiyor. TypeScript bunu doğrudan yapamıyor çünkü yapısal tipleme yüzünden herkes bir toString yazabiliyor. Yapısal-TypeScript karşılıkları iki tane. Biri API sınırındaki brand-tabanlı değer-tipi guard’ı. Diğeri, runtime kapsamına ihtiyaç olan yerde, redact eden metotlu wrapper sınıfı.

Allan Reyes’in “Read-Once Objects” yazısı bu boşluğu doğrudan adlandırıyor. Benzer PII şeklindeki bir pattern için compile-time zorunluluk istiyordu, TypeScript’te ulaşamadı ve bir Semgrep kuralına geri çekildi. Hem brand hem de wrapper sınıfı o boşluğu type system’i terk etmeden kapatıyor.

Bu pattern’in yeri

Compile-time pattern, kazaen sızıntıların büyük bölümünü hallediyor: log context’ine isimle geçirilen alan, span attribute’una iliştirilen PII, metric tag’ine sıkıştırılan email. Ulusal kimlik, IBAN ve adres işleyen düzenlenmiş bir projede reviewer’ların PR’larda yakaladığının çoğu işte bu çıkıyor. Runtime redactor (Pino redact path’leri, cloud scrubber) altta yine çalışıyor, ama daha az iş yapıyor. Brand ile template-literal ve JSON.stringify sızıntılarını topluyor. Sınıf ile .reveal() yanlış kullanımını ve kendi logger’ları üzerinden log yazan üçüncü parti kütüphaneleri topluyor.

Compliance argümanı doğrudan. GDPR Madde 5(1)(c) veri minimizasyonunu zorunlu kılıyor. ICO’nun rehberi de aynı yönde: kişisel veri gerekenden fazla toplanmamalı ya da saklanmamalı. Bu kural log saklamaya da uzanıyor. Compile-time gate, “bu sistem observability’de kazaen PII saklanmasını nasıl önlüyor?” sorusuna verilecek en ucuz savunulabilir cevap. CI’da doğrulanır, git geçmişinde denetlenir. Her bölgede ve her deployment’ta doğru kalan bir runtime yapılandırmasına ihtiyaç duymaz.

Kapanış

Çoğu servis için brand doğru varsayılan. PII, pii ve SafeCtx’i bir contracts paketinde tanımlayın. Logger, metrics ve span fonksiyonlarınızın imzalarını SafeCtx<T> üzerinden geçen generic bir T alacak şekilde değiştirin. Compiler’ın mevcut sızıntıları size tek tek göstermesine izin verin. En yüksek blast radius’a sahip üç ya da dört alan için wrapper sınıfına başvurun (isimle birlikte doğum tarihi, ulusal kimlik, ham IBAN). Aynı contracts paketinde iki uygulamayı yan yana çalıştırmaktan kaçının; servis başına tek bir kimlik seçin. Pino redaction ve cloud scrubber’ı yedek hat olarak yapılandırılmış tutun. Asıl kontrol compile-time’da.

Kaynaklar

  • Zod: Defining schemas (brand, transforms, registries).brand<T>(), .transform ve v4 üst seviye şema formları (z.email(), z.iso.date(), z.uuid()) için referans.
  • TypeScript Handbook: Conditional TypesSafeCtx<T> guard’ının dayandığı conditional type davranışının referansı.
  • TypeScript Handbook: Mapped Types — Çıkarılan argümanın her property’sini gezen [K in keyof T] mapped-type formunun referansı.
  • Learning TypeScript: Branded Types — Intersection tipleriyle yazılan el yapımı brand pattern’leri için kanonik topluluk referansı.
  • Pino: Redaction docsredact.paths için resmi rehber ve redaction path’lerinin kullanıcı girdisinden gelmemesi gerektiğine dair maintainer uyarısı.
  • OWASP Logging Cheat Sheet — Log payload’larında görünmemesi gereken kategorilerin otoriter listesi.
  • Art. 5 GDPR: Principles relating to processing of personal data — Compliance argümanını çapaya bağlayan veri minimizasyonu maddesi (5(1)(c)).
  • Art. 4 GDPR: Definitions — Hangi alanların brand alacağına karar verirken referans alınan kişisel veri tanımı.
  • ICO: Data minimisation — Compile-time gate’i denetim anında savunmak için kullanılan İngiltere regülatör rehberi.
  • Effect: Branded Types — Karşılaştırma noktası; Zod’un compile-time-only brand’ine smart-constructor alternatifi.
  • MDN: Template literals — Template interpolation’ın brand silme davranışı için kaynak.
  • MDN: Spread syntax — Object spread’lerin branded property metadata’sını sıyırdığı ilgili escape hatch’ler için referans.
  • Effect-TS Redacted — Effect’in runtime-only PII wrapper’ı; özel toString / inspect ile değerin yerine <redacted> basıyor. Bu yazıdaki wrapper sınıfının doğrudan kavramsal atası.
  • Pino LogFnFields (PR #2254) — v9.14.0 ile alan-adı never-yasaklama özelliğini getiren Pino PR’ı; burada kullanılan aynı never numarası, ama değer tipi yerine isim üzerinden anahtarlanmış.
  • Rust secrecy crate — Rust ekosisteminin aynı probleme trait coherence üzerinden olgun çözümü; hedefin yaygın olarak tanındığına dair dil ötesi kanıt olarak değerli.
  • Allan Reyes — Read-Once Objects — Bu yazının kapattığı compile-time boşluğunu adıyla anıyor; yazar tam olarak bu garantiyi TypeScript’te istemiş ve Semgrep’e geri çekilmiş.
  • Allan Reyes — Tainted Types — Input sanitization için domain-primitive pattern’i olarak branded/tainted type’lar üzerine kardeş yazı; ilgili teknik, farklı sink (loglar yerine XSS).

İlgili yazılar

MCP İleri Düzey Kalıplar: Yetenekler, İş Akışları, Entegrasyon ve RBAC

Model Context Protocol implementasyonları için kurumsal düzeyde kalıplar: araç bileşimi, çoklu ajan orkestrasyonu, rol tabanlı erişim kontrolü ve production gözlemlenebilirlik.

mcpai-integrationrbac+4
Custom MCP Server Geliştirme: Production-Ready Kılavuz

TypeScript ile organizasyonunuzun internal sistemleri için custom Model Context Protocol serverları nasıl geliştirip, güvenli hale getirip, deploy edeceğinizi öğren. Authentication, monitoring ve Kubernetes deployment örnekleriyle.

typescriptmcpnodejs+5
Type-Safe Lambda Middleware: Middy, Zod ve Builder Pattern ile Enterprise Uygulamalar

Middy builder pattern, Zod validation, feature flags ve secrets management kullanarak enterprise serverless uygulamaları için sürdürülebilir, type-safe Lambda middleware nasıl inşa edilir öğren.

aws-lambdamiddymiddleware+8
HMAC: Nedir, Nasıl Çalışır ve Ne Zaman Yanlış Araçtır

Webhook, imzalı URL ve servisler arası kimlik doğrulama için pratik bir HMAC-SHA-256 rehberi: üç dilde çalışan kod ve dijital imzaya geçmenin gerektiği sınır.

securitycryptographyhmac+5
Idempotency: API'lerde Güvenli Retry için Başlangıç Rehberi

API, ödeme akışı ve mesaj tüketicisi geliştiren yazılımcılar için idempotency'ye pratik bir giriş. HTTP metot semantiği, idempotency key'leri, veritabanı upsert ve yaygın tuzakları çalışan Node.js örnekleriyle anlatır.

idempotencyapi-designdistributed-systems+4