Visualisasi Data for fun and profit

Acquire, Parse, Filter, Mine Represent, Refine, Interact, MapReduce™

Akhir-akhir ini sedang seneng-senengnya dengan Data, dan karena gue taunya Node.js, mari kita coba bermain dengan suatu "data" menggunakan Node.js.

Data tersebut adalah sebuah berkas bernama access.log yang memiliki ukuran 71MB dengan total jumlah baris 245,886 yang dibuat oleh Traefik untuk mencatat setiap permintaan masuk dari klien ke server edge router.

Meskipun data tersebut masih relatif kecil, tapi kita sedang tidak berbicara tentang Big Data™ oke?

Apa yang akan kita lakukan?

Lihat, bagaimana cara membaca data diatas? Apakah data tersebut terlihat "penting"? Tentu tidak, disini kita akan buat penting dengan cara menampilkannya sejelas mungkin menggunakan visualisasi.

Tapi ada tapinya, data yang ada tidak berbentuk JSON yang mana mudah diproses khususnya di JavaScript. Dan karena hanya sebagai contoh saja, kita hanya berurusan dengan data yang memiliki 1 format saja yang akan kita bahas nanti.

Ini cuma sebatas eksperimen, untuk melakukan ini harusnya menggunakan tools ataupun frameworks yang sudah ada terlebih yang sudah menjadi standar industri. Sebagaimana judul diatas, percobaan ini hanyalah untuk bersenang-senang, agar kita sedikit tau bagaimana prosesnya under the hood dengan menggunakan skala yang lebih kecil.

The 7 stages of visualizing data

Sudah kita sebutkan sebelumnya di subjudul yakni ada Acquire, Parse, Filter, Mine, Represent, Refine, dan Interact. MapReduce itu cuma pemanis aja biar pada tertarik, meskipun kita akan berurusan dengan map dan reduce secara praktik.

Kita akan coba bahas step by step dari Acquire sampai Interact, untuk mencoba-coba, silahkan gunakan data yang ada disini.

Acquire

Data diambil dari berkas access.log yang dalam contoh ini sudah menjadi berkas utuh, bukan stream.

Ini kita bisa menggunakan readFileSync dari module bawaan fs di Node.js.

const { resolve } = require('path')
const { readFileSync } = require('fs')

try {
  const buffer = readFileSync(resolve(__dirname, 'access.log'))
  const contents = buffer.toString()

} catch (err) {
  console.error(err)
}

Kode diatas bertugas untuk membuka berkas bernama access.log dan kita ubah Buffer (keluaran dari fungsi readFileSync) menjadi string.

Parse

Jika kita console.log nilai dari contents, data tersebut tidak berpola. Hanyalah kumpulan baris yang memiliki nilai seperti ini:

36.71.0.0 - - [12/Nov/2019:16:34:32 +0700] "GET / HTTP/2.0" 304 0 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:70.0) Gecko/20100101 Firefox/70.0" 1 "Host-blog-evilfactory-id-8" "http://10.0.0.40:2368" 325ms

Yang sebenarnya, data diatas merepresentasikan:

  • remote_IP_address (36.81.0.0)
  • timestamp (2/Nov/2019:16:34:32 +0700)
  • request_method (GET)
  • request_path (/)
  • request_protocol (HTTP/2.0)
  • origin_server_HTTP_status (304)
  • origin_server_content_size (0)
  • request_referrer (0)
  • request_user_agent ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:70.0) Gecko/20100101 Firefox/70.0)
  • number_of_requests_received_since_Traefik_started (1)
  • Traefik_frontend_name (Host-blog-evilfactory-id-8)
  • Traefik_backend_URL (http://10.0.0.40:2368)
  • request_duration_in_ms (325)

Ya, data diatas diambil dari data beneran, gunakan dengan bijak.

Filter

Data yang kita inginkan adalah seperti ini:

[
  {
    timestamp: number,
    ip: string,
    method: string,
    path: string,
    statusCode: number,
    contentSize: number,
    referrer: string,
    userAgent: string,
    durationTime: number
  }
]

Jadi, kita akan membuang data-data yang tidak kita butuhkan untuk kebutuhan sekarang. Disini kita akan membahas bagian parse dan filter sekaligus, agar tulisan tidak terlalu panjang.

Masalahnya, bagaimana kita tau kalau kita sedang di timestamp? Atau sedang di referrer? Dsb? Maka dari itu kita harus melakukan parsing terlebih dahulu!

Mencocokkan Key-Value

Kita harus mencocokkan Key-Value terlebih dahlu dengan cara melakukan splitting terhadap data yang ada, dan meng-kategorikannya. Disini kita akan pakai regex untuk mencocokkannya, yang mana kita akan menggunakan fitur Named capture groups yang akan memudahkan proses peruraian ini.

Perlu diketahui bahwa Named capture groups ini belum mendukung di semua lingkungan, cek kompatibilitasnya disini. Dan untuk lingkungan Node.js versi >= 10.0.0 sudah didukung

Formatnya dari isi yang ada di berkas log tersebut adalah seperti ini:

<ip> <ignore> <userId> [<timestamp>] <method> <path> <protocol> <statusCode> <contentSize> <referrer> <userAgent> <ignore> <traefikFrontend> <durationTime>ms

Yang bila kita ubah menjadi regex, berarti menjadi seperti ini:

/(?<ip>\S+) (?<indent>\S+) (?<userId>\S+) \[(?<timestamp>.+)\] (?<method>\S+) (?<path>\S+) (?<protocol>\S+) (?<statusCode>[0-9]+) (?<contentSize>\S+) (?<referrer>\S+) (?<userAgent>.*) (?<traefikHits>[0-9]+) (?<traefikFrontend>.*) (?<traefikBackend>\S+) (?<durationTime>[0-9]+)/gm

Pusing? Sama. Intinya kita menyesuaikan dengan "struktur" data yang kita inginkan tersebut, bukan? Sekarang mari kita coba.

const input = `36.71.0.0 - - [12/Nov/2019:16:34:32 +0700] "GET / HTTP/2.0" 304 0 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:70.0) Gecko/20100101 Firefox/70.0" 1 "Host-blog-evilfactory-id-8" "http://10.0.0.40:2368" 325ms`

const regex = /(?<ip>\S+) (?<indent>\S+) (?<userId>\S+) \[(?<timestamp>.+)\] (?<method>\S+) (?<path>\S+) (?<protocol>\S+) (?<statusCode>[0-9]+) (?<contentSize>\S+) (?<referrer>\S+) (?<userAgent>.*) (?<traefikHits>[0-9]+) (?<traefikFrontend>.*) (?<traefikBackend>\S+) (?<durationTime>[0-9]+)/gm

let match = regex.exec(input)

const groupedData = []

do {
  groupedData.push(match && match.groups)
} while ((match = regex.exec(input)) !== null)

Sekarang mari kita coba!

Sesuai dengan yang diharapkan! Sekarang mari kita filter.

Filter

Yang kita butuhkan hanyalah data ini:

  • timestamp
  • ip
  • method
  • path
  • statusCode
  • contentSize
  • referrer
  • userAgent
  • durationTime

Mari kita filter data tersebut menggunakan map saja biar singkat. Kita tidak membutuhkan data indent, userId, traefikHits, traefikFrontend, dan traefikBackend, jadi mari kita buang.

const filteredData = groupedData.map(({
  indent,
  userId,
  traefikHits,
  traefikFrontend,
  traefikBackend,
  ...data }) => data)

Mari kita lihat!

Sudah oke juga, ya? Sekarang kita lanjut ke "Mine"

Mining

Oke gue sebenernya gak ngerti-ngerti amat dibagian ini, karena jelek banget nilai kalkulus gue. But whatever, mari kita lakukan mining disini hanya untuk mengambil data Duration time tercepat & terlama aja hahaha.

Disini kita akan menggunakan reduce, so here we go.

// https://twitter.com/faultable/status/1223383659643670529
const durationTimeData = filteredData.map(data => ~~data['durationMap'])

const durationTimeStats = {
  max: durationTimeData.reduce((max, v) => (max >= v ? max : v), 0) + 'ms',
  min: durationTimeData.reduce((min, v) => (min <= v ? min : v), 0) + 'ms'
}

Mari kita coba!

Oke bisa ya? Kita pakai reduce buat menghindari error callstack limit. Dan proses "mine" disini cuma sebagai contoh aja, mungkin kurang lebih seperti itu.

Tapi ini masih kurang sih, mari kita bikin juga timeseries ala-ala nya untuk duration time ini berdasarkan timestamp.

// This is technically true :))
const parseDate = date => new Date(Date.parse(date.replace(':', ' ')))

const dateFormat = date =>
  `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`

const timeSeries = filter.reduce((acc, curr) => {
  const key = dateFormat(parseDate(curr['timestamp']))
  acc[key] = ~~curr['durationTime']

  return acc
}, {})

Kita lihat hasilnya ya

Oke lah ya, let's move on.

Represent

Sekarang mari kita rapihkan menjadi format JSON yang siap dikonsumsi oleh client. Anggep aja ini kita lagi bikin API endpoint, dan data yang ingin kita tampilkan itu seperti ini:

{
  data: [{...}],
  stats: [{...}]
}

Mari kita buat!

const payload = {
  data: filter.slice(0, 2),
  stats: {
    durationTime: {
      timeseries: timeSeries,
      ...stas
    }
  }
}

Sekarang kita lihat

Oke udah mantap ya, proses pengubahan dari teks biasa menjadi JSON juga lumayan memakan effort, sekarang mari kita buat si data tersebut bisa enak ditampilkan.

Refine

Berdasarkan data yang sudah di Represent, mari kita buat data tersebut lebih halus lagi dengan menampilkannya menjadi data yang mudah dimengerti dan lebih interaktif seperti gambar dibawah ini:

Kita akan menggunakan ApexCharts untuk membuat chart nya.

const payload = Object
  .keys(datas)
  .map(data => [new Date(data).valueOf(), datas[data].time])

const options = {
  chart: {
    type: 'area',
    height: 200,
    zoom: {
      enabled: false
    }
  },
  series: [
    {
      name: 'Median',
      data: payload
    }
  ],
  xaxis: {
    type: 'datetime',
    min: new Date('12 Oct 2019').getTime(),
    tickAmount: 3
  },
  fill: {
    type: 'gradient',
    gradient: {
      shadeIntensity: 1,
      opacityFrom: 0.7,
      opacityTo: 0.9,
      stops: [0, 100]
    }
  },
  dataLabels: {
    enabled: false
  },
  stroke: {
    curve: 'straight'
  },
  title: {
    text: 'Duration Time (ms)',
    align: 'left'
  },
  yaxis: {
    opposite: true
  }
}

const chart = new window.ApexCharts(document.getElementById('chart'), options)

chart.render()

Kita tampilkan apa ada nya terlebih dahulu karena ini hanya demi eksperimen aja.

It's done!

Kode terakhir nya adalah seperti ini:

const { readFileSync } = require('fs')

const regex = /(?<ip>\S+) (?<indent>\S+) (?<userId>\S+) \[(?<timestamp>.+)\] (?<method>\S+) (?<path>\S+) (?<protocol>\S+) (?<statusCode>[0-9]+) (?<contentSize>\S+) (?<referrer>\S+) (?<userAgent>.*) (?<traefikHits>[0-9]+) (?<traefikFrontend>.*) (?<traefikBackend>\S+) (?<durationTime>[0-9]+)/gm

try {
  const buffer = readFileSync('./access.log')
  const toString = buffer.toString()

  const json = toString.split('\n')

  const groupedData = []

  for (let entry in json) {
    if (json[entry]) {
      let match = regex.exec(json[entry])
      do {
        groupedData.push(match && match.groups)
      } while ((match = regex.exec(json[entry])) !== null)
    }
  }

  const filteredData = groupedData.map(
    ({
      indent,
      userId,
      traefikHits,
      traefikFrontend,
      traefikBackend,
      ...data
    }) => data
  )

  const durationTimeData = filteredData.map(d => ~~d['durationTime'])

  const parseDate = date => new Date(Date.parse(date.replace(':', ' ')))
  const dateFormat = date =>
    `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`

  const timeseries = filteredData.reduce((acc, curr) => {
    const key = dateFormat(parseDate(curr['timestamp']))

    acc[key] = {
      time: ~~curr['durationTime']
    }

    return acc
  }, {})

  const stas = {
    max: durationTimeData.reduce((max, v) => (max >= v ? max : v), 0) + 'ms',
    min: durationTimeData.reduce((min, v) => (min <= v ? min : v), 0) + 'ms'
  }

  const payload = {
    data: filteredData.slice(0, 2),
    stats: {
      durationTime: {
        timeseries,
        ...stas
      }
    }
  }

  console.log(payload) // res.json(payload)
} catch (err) {
  console.error(err)
}

Dan mari kita lihat berapa waktu yang dibutuhkan untuk memproses berkas access.log yang berukuran 80MB dengan total jumlah baris 247,076:

~3 detik, lumayan.

Penutup

Disini kita gak pakai berkas access.log beneran (sebagaimana yang digunakan lokal) karena ukurannya terlalu besar, juga tidak menyediakan demo yang bisa dicoba langsung untuk bagian memproses datanya.

Cobain jalanin cuplikan kode diatas di mesinmu, pakai file access.log mu ataupun demo yang sudah dilampirkan tadi, dan visualisasikan datamu!

Terima kasih

Demo CodeSandbox: https://codesandbox.io/s/youthful-haibt-ddjzh