Giter Club home page Giter Club logo

go-techlog1c's Introduction

go-techLog1C

Парсер технологического журнала, основанный на стеке технологий Golang goroutines + Redis + Elasticsearch.

Стек является кросс-платформенным.

Упрощенная схема взаимодействия

Запуск парсера

Необходимо иметь установленный инстанс СУБД Redis, а так же Elasticsearch (версия > 7.0) Непосредственно запустить парсер можно командой: go run

или собрать exe/bin командой: go build

Windows: собранный exe можно запускать через планировщик заданий с заданной периодичностью. В этом случае - в настройки задания планировщика нужно прописать рабочую папку в параметрах. Лучшей практикой является использование bat файла, примерное содержание:

@echo off
cd "S:\go-techLog1C\"
techLog1C.exe
echo on

Linux: используйте crontab. Пример запуска парсера с периодичностью 5 минут:

*/5**** /usr/local/techLog1C

Настройки парсера

Все настройки указываются в файле settings.yaml

# Расположение логов тех журнала 1С
patch: "D:\\tmp\\1C_event_log\\1C_event_log"
#
# Удалять табуляции в контекстных строках
delete_tabs_in_contexts: true
#
# Заменять постфиксы виртуальных таблиц в контекстах, например #tt36 на #tt 
# Может использоваться для группировки контекстов
delete_postfix_in_name_virtual_tables: true
#
# Параметры подключения к Redis
redis_addr: "localhost:6379"
redis_login: ""
redis_password: ""
redis_database: 0
#
# Параметры подключения к Elasticsearch
elastic_addr: "http://localhost:9200"
elastic_login: ""
elastic_password: ""
elastic_maxretries: 10
elastic_bulksize: 2000000
#
# Правила формирования индекса Elasticsearch
# Пример: "tech_journal_{event}_yyyyMMddhh", где event - CONN, EXCP, etc...
elastic_indx: "tech_journal_{event}_yyyyMMddhh"
#
# Типы событий тех журнала, которые могут содержать длинные строки '...' и переносы строк \n
tech_log_details_events: "Context|Txt|Descr|DeadlockConnectionIntersections|ManagerList|ServerList|Sql|Sdbl"
#
# Путь, где будут распологаться логи программы
patch_logfile: "D:\\tmp\\techLogData\\log"
#
# Глубина фиксации ошибок при работе программы:
#   1 - только ошибки
#   2 - ошибки и предупреждения 
#   3 - ошибки, предупреждения и информация
log_level: 3
#
# Уровень параллелизма
maxdop: 14
#
# 0 - отключена сортировка файлов по размеру, 1 - сортировка по убыванию, 2 - сортировка по возрастанию
# полезно включать при массовых операциях, для равномерного распределения файлов по потокам
sorting: 0

Redis

NoSQL key-value СУБД. В стеке выполняет роль кэша, для хранения параметров файлов, которые обрабатываются в текущий момент времени и те, которые были обработаны (последняя позиция файла). Почему не используется простой текстовый файл? Все просто - парсер работает в многопоточном режиме, что требует доступ до файла в режиме записи из нескольких потоков. Redis позволяет решать следующие кейсы:

  1. Если файла тех. журнала уже нет, то при запуске парсера анализируются ключи в redis базе и проверяется существование файлов. Если файлы не существуют - ключи таких файлов удаляются.
  2. Так же при запуске еще одного инстанса парсера - будут пропущены файлы тех. журнала, обрабатываемые другими инстансами.

Рекомендуется задать параметр в config файле redis_database, который задает номер базы (в redis 16 баз, от 0 до 15, по умолчанию используется база с индексом 0).

Redis для Windows

Elasticsearch

Используется как конечная точка хранения распарсенных логов 1С. Нереляционная база данных Elasticsearch позволяет достаточно оперативно получать необходимую информацию, используя поисковые обратные индексы. В связке с Kibana - можно строить выборки, используя декларативный KSQL язык запросов. При использовании расширения XPACK (30 day free trial) можно задействовать модуль машинного обучения (Machine Learning), что позволит выявлять аномалии и различные зависимости от показателей тех журнала, например утечки памяти.

Elasticsearch особенности настроек:

Elasticsearch работает на JVM, поэтому очень требователен к настройкам памяти. По умолчанию размер кучи установлен в 1Gb, что крайне мало при массовой операции вставки документов в индексы Elasticsearch.

  1. XMX/XMS рекомендуется установить равным половине оперативной памяти, доступной физической/виртуальной машине. Например, если у вас 16Gb, то возможный вариант настройки: -Xms8G -Xmx8G Документация

  2. Очистка старых индексов (использование index lifecycle policy). Чем детальнее технологический журнал - тем сильнее будет расти его объем, а значит и индексы в Elasticsearch, это может привести к нехватке свободного места на дисках, где хранятся индексы. Лучшая практика - спустя N дней удалять индексы. Все что необходимо - задать политику жизненного цикла индексов https://www.elastic.co/guide/en/elasticsearch/reference/current/set-up-lifecycle-policy.html После этого задать темплейт, который будет включать индексы тех журнала

PUT _template/tech_journal_template
{
  "index_patterns": ["tech-*"],                 
  "settings": {
    "number_of_shards": 1,
    "number_of_replicas": 1,
    "index.lifecycle.name": "tech_journal_policy"    
  }
}

Чтобы ее применить к уже существующим индексам, нужно выполнить следующий запрос

PUT tech_*/_settings
{
  "index.lifecycle.name": "tech_journal_policy" 
}

Составление карт по настроенному тех журналу

Фирма 1С периодически что до добавляет в структуру тех журнала, например, в платформе 8.3.25 было добавлено поле level абсолютно ко всем событиям. Этот момент был учтен в приложении и существующее служебное поле level было переименовано в stack. Чтобы сформировать карты в рамках настроенного ТЖ, без лишних полей - рекомендуется использовать обработку ГенераторКартТехЖурнала.epf, полученные карты необходимо поместить в каталог maps

Известные проблемы

  1. circuit_breaking_exception,  [request] Data too large, data for [<reused_arrays>] would be larger than limit of: Измените параметры XMX/XMS
  2. es_rejected_execution_exception: rejected execution of coordinating operation Уменьшите уровень параллелизма (maxdop), подберите оптимальный размер блока на единицу bulk операции (elastic_bulksize), увеличьте параметр elastic_maxretries в config файле.
  3. Долгая индексация, update mapping index. После загрузки каждого документа - если не задана карта индекса - карта создается, что создает накладные расходы, при количестве документов > 1 млн, обновление карты может не уложиться в таймаут по умолчанию (30 сек.). Поэтому, рекомендуется создавать map карты индекса (см. каталог maps)
  4. При вставке записей в Elasticsearch возникает ошибка [400] validation_exception: Validation Failed: 1: this action would add... Необходимо увеличить количество шардов
curl -u USER:PASSWD -X PUT localhost:9200/_cluster/settings -H "Content-Type: application/json" -d '{ "persistent": { "cluster.max_shards_per_node": "3000" } }'
{"acknowledged":true,"persistent":{"cluster":{"max_shards_per_node":"3000"}},"transient":{}}
  1. Время событий в Кибане показывается относительно UTC. Все что необходимо сделать - выставить в настройках необходимый часовой пояс Настройки часового пояса

go-techlog1c's People

Contributors

nuclearapk avatar zavla avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar

go-techlog1c's Issues

Тип данных long

callwait, cputime содержат символы переноса строк, которые нужно исключать и конвертировать в long

Выделение Context

Правильное выделение Context для событий Call (все что после context - не обязательно относится к контексту, например MName, IName)

14:21.255004-5172004,CALL,1,process=rphost,p:processName=VI_MKK_NEW,OSThread=6652,t:clientID=26844,t:applicationName=1CV8C,t:computerName=fastvps-rds-05,t:connectID=35170,callWait=0,Usr=Горковенко Виталий,SessionID=173879,Context=Система.ПолучитьФорму : Обработка.РабочийСтол.Форма.Форма,Interface=bc15bd01-10bf-413c-a856-ddc907fcd123,IName=IVResourceRemoteConnection,Method=0,CallID=1345,MName=send,Memory=132814285,MemoryPeak=164964503,InBytes=0,OutBytes=3310,CpuTime=4984375

Опечатка в названии параметра path

# Расположение логов тех журнала 1С
patch: "D:\\temp\\1C_log"
...
# Путь, где будут распологаться логи программы
patch_logfile: "D:\\Temp\\fucklogs\\"

Путь до папки принято писать как PATH

Реализовать функциональность

  • Добавить map файл на ttimeout событие
  • Если в пакете содержится 0 файлов - не стартовать холостую горутину
  • Добавить возможность парсить вложенные поля
  • Задавать период хранения логов парсера.

Нужно демо

Вот зашел я в репу, почитал readme и не понял, а в чем профит от использования данного инструмента, т.е. я должен поставить рэдис, эластик, проделать какие-то сложные конфигурации и что я получаю? Повставляй гифки что ли, ну или статейку на инфостарте подробную

fatal error: out of memory

Периодически собранная версия 5e24656 падает с ошибкой fatal error: out of memory.
Есть предположение, с чем это связано? Может какие-то настройки нужно подкрутить?

Полный текст ошибки:

runtime: VirtualAlloc of 5471715328 bytes failed with errno=1455
fatal error: out of memory

runtime stack:
runtime.throw({0xd7978f?, 0xcc6ff30000?})
        C:/Program Files/Go/src/runtime/panic.go:1077 +0x65 fp=0x3d821ff918 sp=0x3d821ff8e8 pc=0x8d7385
runtime.sysUsedOS(0xcb6a122000, 0x14623c000)
        C:/Program Files/Go/src/runtime/mem_windows.go:83 +0x1bb fp=0x3d821ff978 sp=0x3d821ff918 pc=0x8b709b
runtime.sysUsed(...)
        C:/Program Files/Go/src/runtime/mem.go:77
runtime.(*mheap).allocSpan(0x107b960, 0xa311e, 0x0, 0x4b?)
        C:/Program Files/Go/src/runtime/mheap.go:1351 +0x487 fp=0x3d821ffa18 sp=0x3d821ff978 pc=0x8c8547
runtime.(*mheap).alloc.func1()
        C:/Program Files/Go/src/runtime/mheap.go:968 +0x5c fp=0x3d821ffa60 sp=0x3d821ffa18 pc=0x8c7cfc
runtime.systemstack()
        C:/Program Files/Go/src/runtime/asm_amd64.s:509 +0x49 fp=0x3d821ffa70 sp=0x3d821ffa60 pc=0x904e49

goroutine 15 [running]:
runtime.systemstack_switch()
        C:/Program Files/Go/src/runtime/asm_amd64.s:474 +0x8 fp=0xc00061c548 sp=0xc00061c538 pc=0x904de8
runtime.(*mheap).alloc(0x14623c000?, 0xa311e?, 0xb8?)
        C:/Program Files/Go/src/runtime/mheap.go:962 +0x5b fp=0xc00061c590 sp=0xc00061c548 pc=0x8c7c5b
runtime.(*mcache).allocLarge(0x40?, 0x14623c000, 0x0?)
        C:/Program Files/Go/src/runtime/mcache.go:234 +0x85 fp=0xc00061c5d8 sp=0xc00061c590 pc=0x8b5f65
runtime.mallocgc(0x14623c000, 0x0, 0x0)
        C:/Program Files/Go/src/runtime/malloc.go:1123 +0x4f6 fp=0xc00061c640 sp=0xc00061c5d8 pc=0x8ad396
runtime.growslice(0xc91f050000, 0xc0002ed700?, 0x40?, 0x40?, 0xc000018070?)
        C:/Program Files/Go/src/runtime/slice.go:266 +0x4cf fp=0xc00061c6b0 sp=0xc00061c640 pc=0x8ebe2f
main.readFile({{0xc00018c2a0, 0x3d}, 0x11862e327, 0x0, {0xc00018c2d1, 0x8}, {0x10788bf0, 0xedca2701b, 0x10654e0}, {0xc00018c2c5, ...}, ...})
        C:/soft/go-techLog1C-main/main.go:274 +0x16a fp=0xc00061c720 sp=0xc00061c6b0 pc=0xc7c80a
main.jobExtractTechLogs({0xc0009de000, 0x9a9, 0xb62?}, 0x2, 0xc00012c000, 0xc00004e7e0?)
        C:/soft/go-techLog1C-main/main.go:383 +0x398 fp=0xc00061dfa0 sp=0xc00061c720 pc=0xc7d6d8
main.main.func7()
        C:/soft/go-techLog1C-main/main.go:817 +0x34 fp=0xc00061dfe0 sp=0xc00061dfa0 pc=0xc82a74
runtime.goexit()
        C:/Program Files/Go/src/runtime/asm_amd64.s:1650 +0x1 fp=0xc00061dfe8 sp=0xc00061dfe0 pc=0x906e01
created by main.main in goroutine 1
        C:/soft/go-techLog1C-main/main.go:817 +0x11e5

goroutine 1 [chan receive, 11 minutes]:
runtime.gopark(0xc0001672c8?, 0x8ad4a5?, 0x40?, 0x81?, 0x40?)
        C:/Program Files/Go/src/runtime/proc.go:398 +0xce fp=0xc000167260 sp=0xc000167240 pc=0x8d9fee
runtime.chanrecv(0xc00004e7e0, 0xc000167408, 0x1)
        C:/Program Files/Go/src/runtime/chan.go:583 +0x3d0 fp=0xc0001672d8 sp=0xc000167260 pc=0x8a6a50
runtime.chanrecv1(0xc0001676e8?, 0xc0001675c8?)
        C:/Program Files/Go/src/runtime/chan.go:442 +0x12 fp=0xc000167300 sp=0xc0001672d8 pc=0x8a6672
main.main()
        C:/soft/go-techLog1C-main/main.go:821 +0x13a5 fp=0xc000167f40 sp=0xc000167300 pc=0xc828c5
runtime.main()
        C:/Program Files/Go/src/runtime/proc.go:267 +0x2b2 fp=0xc000167fe0 sp=0xc000167f40 pc=0x8d9bd2
runtime.goexit()
        C:/Program Files/Go/src/runtime/asm_amd64.s:1650 +0x1 fp=0xc000167fe8 sp=0xc000167fe0 pc=0x906e01

goroutine 2 [force gc (idle), 11 minutes]:
runtime.gopark(0x0?, 0x0?, 0x0?, 0x0?, 0x0?)
        C:/Program Files/Go/src/runtime/proc.go:398 +0xce fp=0xc000035fa8 sp=0xc000035f88 pc=0x8d9fee
runtime.goparkunlock(...)
        C:/Program Files/Go/src/runtime/proc.go:404
runtime.forcegchelper()
        C:/Program Files/Go/src/runtime/proc.go:322 +0xb8 fp=0xc000035fe0 sp=0xc000035fa8 pc=0x8d9e78
runtime.goexit()
        C:/Program Files/Go/src/runtime/asm_amd64.s:1650 +0x1 fp=0xc000035fe8 sp=0xc000035fe0 pc=0x906e01
created by runtime.init.6 in goroutine 1
        C:/Program Files/Go/src/runtime/proc.go:310 +0x1a

goroutine 3 [GC sweep wait]:
runtime.gopark(0x1?, 0x0?, 0x0?, 0x0?, 0x0?)
        C:/Program Files/Go/src/runtime/proc.go:398 +0xce fp=0xc000037f78 sp=0xc000037f58 pc=0x8d9fee
runtime.goparkunlock(...)
        C:/Program Files/Go/src/runtime/proc.go:404
runtime.bgsweep(0x0?)
        C:/Program Files/Go/src/runtime/mgcsweep.go:321 +0xdf fp=0xc000037fc8 sp=0xc000037f78 pc=0x8c4a5f
runtime.gcenable.func1()
        C:/Program Files/Go/src/runtime/mgc.go:200 +0x25 fp=0xc000037fe0 sp=0xc000037fc8 pc=0x8b9d85
runtime.goexit()
        C:/Program Files/Go/src/runtime/asm_amd64.s:1650 +0x1 fp=0xc000037fe8 sp=0xc000037fe0 pc=0x906e01
created by runtime.gcenable in goroutine 1
        C:/Program Files/Go/src/runtime/mgc.go:200 +0x66

goroutine 4 [GC scavenge wait]:
runtime.gopark(0xf7808c?, 0xe38916?, 0x0?, 0x0?, 0x0?)
        C:/Program Files/Go/src/runtime/proc.go:398 +0xce fp=0xc000045f70 sp=0xc000045f50 pc=0x8d9fee
runtime.goparkunlock(...)
        C:/Program Files/Go/src/runtime/proc.go:404
runtime.(*scavengerState).park(0x10655e0)
        C:/Program Files/Go/src/runtime/mgcscavenge.go:425 +0x49 fp=0xc000045fa0 sp=0xc000045f70 pc=0x8c22c9
runtime.bgscavenge(0x0?)
        C:/Program Files/Go/src/runtime/mgcscavenge.go:658 +0x59 fp=0xc000045fc8 sp=0xc000045fa0 pc=0x8c2879
runtime.gcenable.func2()
        C:/Program Files/Go/src/runtime/mgc.go:201 +0x25 fp=0xc000045fe0 sp=0xc000045fc8 pc=0x8b9d25
runtime.goexit()
        C:/Program Files/Go/src/runtime/asm_amd64.s:1650 +0x1 fp=0xc000045fe8 sp=0xc000045fe0 pc=0x906e01
created by runtime.gcenable in goroutine 1
        C:/Program Files/Go/src/runtime/mgc.go:201 +0xa5

goroutine 5 [finalizer wait, 11 minutes]:
runtime.gopark(0x400000?, 0x100039e70?, 0x0?, 0x0?, 0x10ba680?)
        C:/Program Files/Go/src/runtime/proc.go:398 +0xce fp=0xc000039e28 sp=0xc000039e08 pc=0x8d9fee
runtime.runfinq()
        C:/Program Files/Go/src/runtime/mfinal.go:193 +0x107 fp=0xc000039fe0 sp=0xc000039e28 pc=0x8b8e47
runtime.goexit()
        C:/Program Files/Go/src/runtime/asm_amd64.s:1650 +0x1 fp=0xc000039fe8 sp=0xc000039fe0 pc=0x906e01
created by runtime.createfing in goroutine 1
        C:/Program Files/Go/src/runtime/mfinal.go:163 +0x3d

goroutine 18 [GC worker (idle)]:
runtime.gopark(0x1cbf22a6847c0?, 0x3?, 0x94?, 0x9b?, 0xc000047fb0?)
        C:/Program Files/Go/src/runtime/proc.go:398 +0xce fp=0xc000047f50 sp=0xc000047f30 pc=0x8d9fee
runtime.gcBgMarkWorker()
        C:/Program Files/Go/src/runtime/mgc.go:1293 +0xe5 fp=0xc000047fe0 sp=0xc000047f50 pc=0x8bb785
runtime.goexit()
        C:/Program Files/Go/src/runtime/asm_amd64.s:1650 +0x1 fp=0xc000047fe8 sp=0xc000047fe0 pc=0x906e01
created by runtime.gcBgMarkStartWorkers in goroutine 1
        C:/Program Files/Go/src/runtime/mgc.go:1217 +0x1c

goroutine 13 [running]:
        goroutine running on other thread; stack unavailable
created by main.main in goroutine 1
        C:/soft/go-techLog1C-main/main.go:817 +0x11e5

goroutine 12 [running]:
        goroutine running on other thread; stack unavailable
created by main.main in goroutine 1
        C:/soft/go-techLog1C-main/main.go:817 +0x11e5

goroutine 11 [GC worker (idle), 11 minutes]:
runtime.gopark(0x0?, 0x0?, 0x0?, 0x0?, 0x0?)
        C:/Program Files/Go/src/runtime/proc.go:398 +0xce fp=0xc000175f50 sp=0xc000175f30 pc=0x8d9fee
runtime.gcBgMarkWorker()
        C:/Program Files/Go/src/runtime/mgc.go:1293 +0xe5 fp=0xc000175fe0 sp=0xc000175f50 pc=0x8bb785
runtime.goexit()
        C:/Program Files/Go/src/runtime/asm_amd64.s:1650 +0x1 fp=0xc000175fe8 sp=0xc000175fe0 pc=0x906e01
created by runtime.gcBgMarkStartWorkers in goroutine 1
        C:/Program Files/Go/src/runtime/mgc.go:1217 +0x1c

goroutine 19 [GC worker (idle), 3 minutes]:
runtime.gopark(0x10ba680?, 0x1?, 0xa8?, 0x53?, 0x0?)
        C:/Program Files/Go/src/runtime/proc.go:398 +0xce fp=0xc000171f50 sp=0xc000171f30 pc=0x8d9fee
runtime.gcBgMarkWorker()
        C:/Program Files/Go/src/runtime/mgc.go:1293 +0xe5 fp=0xc000171fe0 sp=0xc000171f50 pc=0x8bb785
runtime.goexit()
        C:/Program Files/Go/src/runtime/asm_amd64.s:1650 +0x1 fp=0xc000171fe8 sp=0xc000171fe0 pc=0x906e01
created by runtime.gcBgMarkStartWorkers in goroutine 1
        C:/Program Files/Go/src/runtime/mgc.go:1217 +0x1c

goroutine 20 [GC worker (idle), 3 minutes]:
runtime.gopark(0x1cbc930859190?, 0x1?, 0xf4?, 0xa2?, 0x0?)
        C:/Program Files/Go/src/runtime/proc.go:398 +0xce fp=0xc000173f50 sp=0xc000173f30 pc=0x8d9fee
runtime.gcBgMarkWorker()
        C:/Program Files/Go/src/runtime/mgc.go:1293 +0xe5 fp=0xc000173fe0 sp=0xc000173f50 pc=0x8bb785
runtime.goexit()
        C:/Program Files/Go/src/runtime/asm_amd64.s:1650 +0x1 fp=0xc000173fe8 sp=0xc000173fe0 pc=0x906e01
created by runtime.gcBgMarkStartWorkers in goroutine 1
        C:/Program Files/Go/src/runtime/mgc.go:1217 +0x1c

goroutine 21 [GC worker (idle)]:
runtime.gopark(0x1cbe7d09c67e4?, 0x3?, 0x90?, 0x42?, 0x0?)
        C:/Program Files/Go/src/runtime/proc.go:398 +0xce fp=0xc00048ff50 sp=0xc00048ff30 pc=0x8d9fee
runtime.gcBgMarkWorker()
        C:/Program Files/Go/src/runtime/mgc.go:1293 +0xe5 fp=0xc00048ffe0 sp=0xc00048ff50 pc=0x8bb785
runtime.goexit()
        C:/Program Files/Go/src/runtime/asm_amd64.s:1650 +0x1 fp=0xc00048ffe8 sp=0xc00048ffe0 pc=0x906e01
created by runtime.gcBgMarkStartWorkers in goroutine 1
        C:/Program Files/Go/src/runtime/mgc.go:1217 +0x1c

goroutine 14 [running]:
        goroutine running on other thread; stack unavailable
created by main.main in goroutine 1
        C:/soft/go-techLog1C-main/main.go:817 +0x11e5

goroutine 16 [running]:
        goroutine running on other thread; stack unavailable
created by main.main in goroutine 1
        C:/soft/go-techLog1C-main/main.go:817 +0x11e5

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.