Разбираться с падениями программы у конечных пользователей — дело важное, но довольно тяжкое. Доступа к машине клиента обычно нет; если есть доступ, то нет отладчика; когда есть отладчик, оказывается, что проблема не воспроизводится и т.п. Что делать, когда нет даже возможности собрать специальную версию приложения и установить её клиенту? Тогда добро пожаловать под кат!

Итак, в терминах ТРИЗ имеем техническое противоречие: нам необходимо изменить программу, чтобы она писала логи/отправляла крэшрепорты, но возможности изменить программу нет. Уточним, нет возможности изменить её естественным путём, добавить нужный функционал, пересобрать и установить клиенту. Поэтому, мы, следуя заветам гуру терморектального криптоанализа, изменим её противоестественным путём!

Встроим в программу свой крэш-репортер, в том числе для таких сложных случаев и писали. Разумеется, никто не мешает использовать приведённые далее подходы для внедрения в программу другого кода, изначально непредусмотренного разработчиками.

Итак, нам надо, чтобы managed приложение само, каким-то «волшебным образом», загрузило необходимые сборки и выполнило код инициализации:

LogifyAlert client = LogifyAlert.Instance;
client.ApiKey = "my-api-key";
client.StartExceptionsHandling();

Что-ж, погнали.

Необходимая нам, «волшебная» технология существует и называется DLL-injection, и будет представлять из себя загрузчик, который запустит приложение (или приаттачится к уже запущенному), и внедрит в процесс приложения нужную нам DLL.

Выгдядит это следующим образом

Пачка Interop-ов
[DllImport("kernel32.dll")]
static extern IntPtr OpenProcess(int dwDesiredAccess, bool bInheritHandle, int dwProcessId);
[DllImport("kernel32.dll", CharSet = CharSet.Auto)]
static extern IntPtr GetModuleHandle(string lpModuleName);
[DllImport("kernel32", CharSet = CharSet.Ansi, ExactSpelling = true, SetLastError = true)]
static extern IntPtr GetProcAddress(IntPtr hModule, string procName);
[DllImport("kernel32.dll", SetLastError = true, ExactSpelling = true)]
static extern IntPtr VirtualAllocEx(IntPtr hProcess, IntPtr lpAddress, uint dwSize, AllocationType flAllocationType, uint flProtect);
[DllImport("kernel32.dll", SetLastError = true, ExactSpelling = true)]
static extern bool VirtualFreeEx(IntPtr hProcess, IntPtr lpAddress, uint dwSize, AllocationType dwFreeType);
[DllImport("kernel32.dll", SetLastError = true)]
static extern bool WriteProcessMemory(IntPtr hProcess, IntPtr lpBaseAddress, byte[] lpBuffer, uint nSize, out UIntPtr lpNumberOfBytesWritten);
[DllImport("kernel32.dll")]
static extern IntPtr CreateRemoteThread(IntPtr hProcess,
IntPtr lpThreadAttributes, uint dwStackSize, IntPtr lpStartAddress, IntPtr lpParameter, uint dwCreationFlags, IntPtr lpThreadId);
[DllImport("kernel32.dll", SetLastError = true)]
static extern UInt32 WaitForSingleObject(IntPtr hHandle, UInt32 dwMilliseconds);
[DllImport("kernel32.dll", SetLastError = true)]
[ReliabilityContract(Consistency.WillNotCorruptState, Cer.Success)]
[SuppressUnmanagedCodeSecurity]
[return: MarshalAs(UnmanagedType.Bool)]
static extern bool CloseHandle(IntPtr hObject);

[Flags]
public enum AllocationType {
    ReadWrite = 0x0004,
    Commit = 0x1000,
    Reserve = 0x2000,
    Decommit = 0x4000,
    Release = 0x8000,
    Reset = 0x80000,
    Physical = 0x400000,
    TopDown = 0x100000,
    WriteWatch = 0x200000,
    LargePages = 0x20000000
}
public const uint PAGE_READWRITE = 4;
public const UInt32 INFINITE = 0xFFFFFFFF;


Получаем доступ к процессу приложения по идентификатору процесса (PID), и внедряем в него DLL-ку:

int access = PROCESS_CREATE_THREAD | PROCESS_QUERY_INFORMATION |
             PROCESS_VM_OPERATION | PROCESS_VM_WRITE | PROCESS_VM_READ;
IntPtr procHandle = OpenProcess(access, false, dwProcessId);
InjectDll(procHandle, BootstrapDllPath);

Если мы сами запустили дочерний процесс, то для этого даже права администратора не понадобятся. Если приаттачились, то придется озаботиться правами:

static Process AttachToTargetProcess(RunnerParameters parameters) {
    if (!String.IsNullOrEmpty(parameters.TargetProcessCommandLine))
        return StartTargetProcess(parameters.TargetProcessCommandLine,
                                  parameters.TargetProcessArgs);
    else if (parameters.Pid != 0) {
        Process.EnterDebugMode();
        return Process.GetProcessById(parameters.Pid);
    }
    else
        return null;
}

И в манифесте приложения:

<requestedExecutionLevel level="requireAdministrator" uiAccess="false" />

Далее узнаем адрес функции LoadLibraryW и вызываем её в чужом процессе, указывая имя DLL-ки, которую надо загрузить. Адрес функции мы получаем в своём процессе, а вызов по адресу делаем в чужом. Это прокатывает, так как библиотека kernel32.dll во всех процессах имеет один и тот же базовый адрес. Даже если это когда-то изменится (что вряд ли), далее будет показано, как можно решить вопрос в случае разных базовых адресов.

Код InjectDll и MakeRemoteCall
static bool InjectDll(IntPtr procHandle, string dllName) {
    const string libName = "kernel32.dll";
    const string procName = "LoadLibraryW";
    IntPtr loadLibraryAddr = GetProcAddress(GetModuleHandle(libName), procName);
    if (loadLibraryAddr == IntPtr.Zero) {
        return false;
    }

    return MakeRemoteCall(procHandle, loadLibraryAddr, dllName);
}
static bool MakeRemoteCall(IntPtr procHandle, IntPtr methodAddr, string argument) {
    uint textSize = (uint)Encoding.Unicode.GetByteCount(argument);
    uint allocSize = textSize + 2;
    IntPtr allocMemAddress;
    AllocationType allocType = AllocationType.Commit | AllocationType.Reserve;
    allocMemAddress = VirtualAllocEx(procHandle,
                                     IntPtr.Zero,
                                     allocSize,
                                     allocType,
                                     PAGE_READWRITE);
    if (allocMemAddress == IntPtr.Zero)
        return false;

    UIntPtr bytesWritten;
    WriteProcessMemory(procHandle,
                       allocMemAddress,
                       Encoding.Unicode.GetBytes(argument),
                       textSize,
                       out bytesWritten);

    bool isOk = false;
    IntPtr threadHandle;
    threadHandle = CreateRemoteThread(procHandle,
                                      IntPtr.Zero,
                                      0,
                                      methodAddr,
                                      allocMemAddress,
                                      0,
                                      IntPtr.Zero);
    if (threadHandle != IntPtr.Zero) {
        WaitForSingleObject(threadHandle, Win32.INFINITE);
        isOk = true;
    }

    VirtualFreeEx(procHandle, allocMemAddress, allocSize, AllocationType.Release);
    if (threadHandle != IntPtr.Zero)
        Win32.CloseHandle(threadHandle);
    return isOk;
}

Что за жесть тут написана? Нам надо передать строковый параметр в вызов LoadLibraryW в чужом процессе. Для этого строчку надо записать в адресное пространство чужого процесса, чем и занимаются VirtualAlloc и WriteProcessMemory. Далее создаём thread в чужом процессе, адресом, выполняющий LoadLibraryW с параметром, который мы только что записали. Дожидаемся завершения thread и чистим за собой память.


Но, к сожалению, технология применима только для обычных DLL, а у нас managed-сборки. Картина Репина «Приплыли»!

Дело в том, что у managed-сборки нет точки входа, аналога DllMain, поэтому, даже если мы внедрим её в процесс как обычную DLL, сборка не сможет автоматически получить управление.

Можно ли передать управление вручную? Теоретически есть 2 пути: использовать module initializer, или экспортировать функцию из managed-сборки и позвать её. Сразу скажу, что штатными средствами C# ни то, ни другое сделать нельзя. Инициализатор модуля можно прикрутить, например, при помощи ModuleInit.Fody, но беда в том, что инициализатор модуля сам по себе не выполнится, надо сперва обратиться к какому-нибудь типу в сборке. Как говаривал кот Матроскин: «Чтобы продать что-нибудь ненужное, нужно сначала купить что-нибудь ненужное, а у нас денег нет!»

Для экспортов, теоретически, есть UnmanagedExports, но у меня оно слёту не завелось, да необходимость и собирать 2 различных по битности варианта managed сборки (AnyCPU не поддерживается), меня оттолкнуло.

Похоже, в этом направлении нам уже ничего не светит. А если изолентой обмотать? А если внедрить в процесс unmanaged DLL, а уже из неё попробовать позвать managed сборку?

Оказывается, можно
HRESULT InjectDotNetAssembly(
    /* [in] */ LPCWSTR pwzAssemblyPath,
    /* [in] */ LPCWSTR pwzTypeName,
    /* [in] */ LPCWSTR pwzMethodName,
    /* [in] */ LPCWSTR pwzArgument
) {
    HRESULT result;
    ICLRMetaHost *metaHost = NULL;
    ICLRRuntimeInfo *runtimeInfo = NULL;
    ICLRRuntimeHost *runtimeHost = NULL;

    // Load .NET
    result = CLRCreateInstance(CLSID_CLRMetaHost, IID_PPV_ARGS(&metaHost));
    result = metaHost->GetRuntime(L"v4.0.30319", IID_PPV_ARGS(&runtimeInfo));
    result = runtimeInfo->GetInterface(CLSID_CLRRuntimeHost,
                                        IID_PPV_ARGS(&runtimeHost));
    result = runtimeHost->Start();

    // Execute managed assembly
    DWORD returnValue;
    result = runtimeHost->ExecuteInDefaultAppDomain(
        pwzAssemblyPath,
        pwzTypeName,
        pwzMethodName,
        pwzArgument,
        &returnValue);

    if (metaHost != NULL)
        metaHost->Release();
    if (runtimeInfo != NULL)
        runtimeInfo->Release();
    if (runtimeHost != NULL)
        runtimeHost->Release();
    return result;
} 


Выглядит не так уж страшно, и вроде как обещает даже сделать вызов в AppDomain-е по умолчанию. Непонятно, правда, в каком thread, но и на том спасибо.

Теперь нам надо вызвать этот код из нашего загрузчика.

Воспользуемся допущением о том, что смещение адреса функции от адреса, по которому загружена DLL, есть величина постоянная для любого процесса.

Загружаем нужную DLL себе в процесс при помощи LoadLibrary, получаем базовый адрес. Находим адрес вызываемой функции через GetProcAddress.

static long GetMethodOffset(string dllPath, string methodName) {
    IntPtr hLib = Win32.LoadLibrary(dllPath);
    if (hLib == IntPtr.Zero)
        return 0;

    IntPtr call = Win32.GetProcAddress(hLib, methodName);
    if (call == IntPtr.Zero)
        return 0;
    long result = call.ToInt64() - hLib.ToInt64();
    Win32.FreeLibrary(hLib);
    return result;
}

Остался последний кусочек пазла, найти базовый адрес DLL в чужом процессе:

static ulong GetRemoteModuleHandle(Process process, string moduleName) {
    int count = process.Modules.Count;
    for (int i = 0; i < count; i++) {
        ProcessModule module = process.Modules[i];
        if (module.ModuleName == moduleName)
            return (ulong)module.BaseAddress;
    }
    return 0;
}

И, наконец, получаем адрес нужной функции в чужом процессе.

long offset = GetMethodOffset(BootstrapDllPath, "InjectManagedAssembly");
InjectDll(procHandle, BootstrapDllPath);
ulong baseAddr = GetRemoteModuleHandle(process, Path.GetFileName(BootstrapDllPath));
IntPtr remoteAddress = new IntPtr((long)(baseAddr + (ulong)offset));

Делаем вызов по полученному адресу, точно так же, как вызывали LoadLibrary в чужом процессе, через MakeRemoteCall (см. выше)

Неудобно то, что мы можем передать только одну строку, а для вызова managed сборки надо понадобится аж 4. Чтобы не изобретать велосипед, сформируем строку как command line, а на unmanaged стороне без шума и пыли воспользуемся системной функцией CommandLineToArgvW:

HRESULT InjectManagedAssemblyCore(_In_ LPCWSTR lpCommand) {
    LPWSTR *szArgList;
    int argCount;
    szArgList = CommandLineToArgvW(lpCommand, &argCount);
    if (szArgList == NULL || argCount < 3)
        return E_FAIL;

    LPCWSTR param;
    if (argCount >= 4)
        param = szArgList[3];
    else
        param = L"";

    HRESULT result = InjectDotNetAssembly(
        szArgList[0],
        szArgList[1],
        szArgList[2],
        param
    );
    LocalFree(szArgList);
    return result;
}

Заметим также, что пересчёт смещения функции неявно предполагает, что битность процессов загрузчика и целевого приложения строго одинакова. Т.е. никуда мы от битности не денемся, и нам придётся делать как 2 варианта загрузчика (32 и 64 бит), так и 2 варианта unmanaged DLL (просто потому, что в процесс можно загрузить только DLL правильной битности).

Поэтому, при работе под 64-разрядной OS, добавим проверку на совпадение битности процессов. Свой процесс:

Environment.Is64BitProcess

Чужой процесс:

[DllImport("kernel32.dll", CallingConvention = CallingConvention.Winapi)]
[return: MarshalAs(UnmanagedType.Bool)]
static extern bool IsWow64Process([In] IntPtr process, [Out] out bool wow64Process);

public static bool Is64BitProcess(Process process) {
    bool isWow64;
    if (!IsWow64Process(process.Handle, out isWow64)) {
        return false;
    }
    return !isWow64;
}

static bool IsCompatibleProcess(Process process) {
    if (!Environment.Is64BitOperatingSystem)
        return true;
    bool is64bitProcess = Is64BitProcess(process);
    return Environment.Is64BitProcess == is64bitProcess;
}

Делаем managed сборку, с показом MessageBox-а:

public static int RunWinForms(string arg) {
    InitLogifyWinForms();
}

static void InitLogifyWinForms() {
    MessageBox.Show("InitLogifyWinForms");
}

Проверяем, всё вызывается, MessageBox показывается. УРА!



Заменяем MessageBox на пробную инициализацию крэш-репортера:

static void InitLogifyWinForms() {
    try {
        LogifyAlert client = LogifyAlert.Instance;
        client.ApiKey = "my-api-key";
        client.StartExceptionsHandling();
    }
    catch (Exception ex) {
    }
}

Пишем тестовое WinForms приложение, которое вызывает исключение при нажатии кнопки.

void button2_Click(object sender, EventArgs e) {
    object o = null;
    o.ToString();
}

Вроде бы всё. Запускаем, проверяем… И тишина. А вдоль дороги, мёртвые с косами стоят.

Вставляем код крэш-репортера прямо в тестовое приложение, добавляем референсы.

static void Main() {
    InitLogifyWinForms();

    Application.EnableVisualStyles();
    Application.SetCompatibleTextRenderingDefault(false);
    Application.Run(new Form1());
}

Проверяем – работает, значит не в коде инициализации дело. Может с thread-ами что-то не так? Меняем:

static void Main() {
    Thread thread = new Thread(InitLogifyWinForms);
    thread.Start();

    Application.EnableVisualStyles();
    Application.SetCompatibleTextRenderingDefault(false);
    Application.Run(new Form1());
}

Проверяем, опять работает. Что же не так?! У меня нет ответа на этот вопрос. Может кто-нибудь другой сумеет пролить свет на причины такого поведения события AppDomain.UnhandledException. Тем не менее обходное решение я нашёл. Ждём появления хотя бы одного окна в приложении и делаем BeginInvoke через очередь сообщения этого окна:

Workaround, 18+
public static int RunWinForms(string arg) {
    bool isOk = false;
    try {
        const int totalTimeout = 5000;
        const int smallTimeout = 1000;
        int count = totalTimeout / smallTimeout;
        for (int i = 0; i < count; i++) {
            if (Application.OpenForms == null || Application.OpenForms.Count <= 0)
                Thread.Sleep(smallTimeout);
            else {
                Delegate call = new InvokeDelegate(InitLogifyWinForms);
                Application.OpenForms[0].BeginInvoke(call);
                isOk = true;
                break;
            }
        }
        if (!isOk) {
            InitLogifyWinForms();
        }
        return 0;
    }
    catch {
        return 1;
    }
}



И, о чудо, оно завелось. Отметим серьёзный минус: для консольных приложений неработоспособно.

Осталось навести блеск, и научить крэшрепортер конфигурироваться из собственного config-файла. Оказыватся сделать реально, хоть и на редкость мудрёно:

ExeConfigurationFileMap map = new ExeConfigurationFileMap();
map.ExeConfigFilename = configFileName;

Configuration config = ConfigurationManager.OpenMappedExeConfiguration(map, ConfigurationUserLevel.None);


Пишем конфиг
<?xml version="1.0" encoding="utf-8" ?>
<configuration>
  <configSections>
    <section name="logifyAlert" type="DevExpress.Logify.LogifyConfigSection, Logify.Alert.Win"/>
  </configSections>
  <logifyAlert>
    <collectBreadcrumbs value="1" />
    <breadcrumbsMaxCount value="500" />
    <apiKey value="my-api-key"/>
    <confirmSend value="false"/>
    <offlineReportsEnabled value="false"/>
    <offlineReportsDirectory value="offlineReports"/>
    <offlineReportsCount value="10"/>
  </logifyAlert>
</configuration>



Кладём его рядом с exe-шником приложения. Запускаем, проверяем, упс.



Какого? Нужная сборка уже загружена в процесс, а рантайм почему-то решил поискать её по-новой. Пробуем использовать полное имя сборки, с тем же успехом.

Честно говоря, исследовать причины подобного (ИМХО, не вполне логичного) поведения не стал. Есть 2 пути обойти проблему: подписаться на AppDomain.AssemblyResolve и показать системе, где находится искомая сборка; или же просто и незатейливо подкопировать нужные сборки в каталог с exe-шником. Памятуя граблях со странным поведением AppDomain.UnhandledException, не стал рисковать и подкопировал сборки.

Пересобираем, пробуем. Успешно конфигурится и присылает крэш репорт.



Далее рутина, приделываем CLI-интерфейс к загрузчику и в целом причёсываем проект.

CLI
LogifyRunner (C) 2017 DevExpress Inc.

Usage:

LogifyRunner.exe [--win] [--wpf] [--pid=value>] [--exec=value1, ...]

--win Target process is WinForms application
--wpf Target process is WPF application
--pid Target process ID. Runner will be attached to process with specified ID.
--exec Target process command line

NOTE: logify.config file should be placed to the same directory where the target process executable or LogifyRunner.exe is located.
Read more about config file format at: https://github.com/DevExpress/Logify.Alert.Clients/tree/develop/dotnet/Logify.Alert.Win#configuration


Теперь имеем в арсенале специалиста техподдержки простое и дубовое средство, позволяющее получить крэш-репорт приложения (а также действия пользователя, предшествующие падению), в которое крэш-репортер изначально не был встроен.

PS:

Исходники на github.
Если кому интересно, сайт проекта и документация. Также вводная статья про Logify здесь, на Хабре.

Комментарии (14)


  1. Einherjar
    12.12.2017 17:32

    Для экспортов, теоретически, есть UnmanagedExports, но у меня оно слёту не завелось, да необходимость и собирать 2 различных по битности варианта managed сборки (AnyCPU не поддерживается), меня оттолкнуло.

    UnmanagedExports по ходу вообще нерабочий пакет, у меня тоже не заработал, разбираться в чужих багах не хотелось и в итоге делал свою реализацию в postbuild event. Две различных по битности managed сборки по моему не более отталкивающие чем 2 различных по битности unmanaged.


    1. mviorno Автор
      12.12.2017 17:47

      Да, с битностью, как ни крути, ни верти — 2 сборки где-нибудь да получаются. Интересно, если, экспортировать функцию из managed-сборки — вылезут ли те же самые грабли с подпиской на UnhandledException. Если не вылезут, это хороший плюс такому подходу и потенциальная работоспособность тулзы в консольном приложении.


  1. snuk182
    12.12.2017 19:26

    КДПВ огонь!


  1. Deosis
    13.12.2017 07:48

    Не увидел проверку очевидного варианта: написать загрузчик приложения, который инициализирует логи и передаст управление основной программе.


    public static void Main(){
      InitLogs();
      YourApp.Program.Main();
    }


    1. mviorno Автор
      13.12.2017 11:37

      Ваш вариант, несмотря на простоту, потенциально опасен тем, что кардинально меняет процесс запуска приложения.

      Первое и самое очевидное — надо добиться, чтобы использовалась ровно та же версия рантайма, что и в приложении, которое запускаем. Значит как минимум шаманить с конфигами вручную.

      Второе, те самые конфиги. Приложение будет пытаться подхватить не свой конфиг, а конфиг загрузчика. Решение (не уверен, что полноценное) — заменить конфиг загрузчика копией конфига приложения.

      Третье — точки входа в приложение часто помечаются атрибутами, задающими COM threading model, например STAThread. «Решение» — писать по отдельному загрузчику на каждую threading model. Какой из них использовать, выбирать или вручную, или писать ещё один, головной загрузчик.

      Четвёртое. Если приложение где-то в своих потрохах использует Assembly.GetEntryAssembly, то его может ждать большой сюрприз.

      Будут ещё технические моменты по правильному определению точки входа приложения (а они разные бывают, взять хотя бы приложение на VB). По запуску приложения через reflection, передаче ему командной строки — но это всё решаемо в рабочем порядке.

      Полагаю, что если не пытаться делать универсальный загрузчик, а делать его разово под конкретное приложение, с учётом особенностей этого приложения — то, при аккуратной реализации, предложенный подход вполне применим.

      PS:
      Есть хорошая статья, описывающая, что делает CLR, перед тем как стартовать приложение. Не так уж и мало мест, где что-то ещё может пойти не совсем так, как ожидалось.


      1. mayorovp
        13.12.2017 15:23

        Есть же Thread.SetApartmentState, с его помощью можно установить правильную COM threading model выдернув ее через рефлексию.

        А замена конфига загрузчика конфигом приложения заодно решит проблему правильного рантайма :-)

        Кстати, можно создать новый AppDomain и запустить сборку в нем. В нем можно заменить главный конфиг и входную сборку (а вот версию рантайма придется копировать в конфиг загрузчика).


  1. mayorovp
    13.12.2017 15:29
    +1

    Выглядит не так уж страшно, и вроде как обещает даже сделать вызов в AppDomain-е по умолчанию. Непонятно, правда, в каком thread, но и на том спасибо.

    А в каком потоке кроме текущего может выполняться блокирующий вызов?


    Кстати, с этим могут быть проблемы: текущий же поток в DllMain висит, а там не все winapi разрешено использовать. Надо не забыть про трюк с APC на свой же поток, в котором уже делать основную работу.


    Кстати, нет ничего сложного и в том чтобы в другом процессе на лету написать новую функцию. Это снимет ограничение в 1 строковый параметр для передачи.


  1. mayorovp
    13.12.2017 15:42
    +1

    По поводу поведения AppDomain.UnhandledException: так исключение-то не является Unhandled! Оно перехватывается и обрабатывается в цикле обработки сообщений.


    Чтобы его перехватить, надо подписываться на Application.ThreadException (что и делает библиотека). Но это событие — локальное для потока!


    Так что идея перейти в основной поток перед инициализацией библиотеки была правильная. Только надо не забыть обработать случай безоконного приложения (в таком случае надо инициализировать библиотеку в текущем потоке), а также WPF приложения (у WPF своя реализация цикла обработки сообщений).


  1. asedovski
    15.12.2017 12:31

    А почему нельзя было снять дамп приложения при ошибке (либо стандартным taskmanager либо procdump либо настроить инструментами от MS dump по условию) а потом в теплой ламповой обстановке открыть его либо Ultimate VS 2013 (начиная с этой версии в линейке ultimate есть heapView для управляемых приложений — оч классная штука) либо уже захардкориться windbg? Это я про аварийные случаи (например ну кончилась память а локально ну никак не воспризвести и вроде бы всеж правильно сделано то), ибо ловить непойманные исключения и слезно просить их отослать разработчикам должно быть вроде как штатным функционалом любой сложной системы…


  1. mviorno Автор
    15.12.2017 16:34

    Насчёт штатного функционала сложной системы — эх, если бы всё было так радужно, не было бы у службы поддержки проблем.

    Наша ситуаций такова. Мы разрабатываем компоненты, продаём их нашим клиентам, программистам. Те, в свою очередь, пишут с их использованием софт для своих заказчиков. Далее, довольно типовая тупиковая ситуация. Из 50 заказчиков у одного софт подпадывает. Заказчик жалуется своему поставщику, тот в свою очередь жалуется нам в саппорт. При этом проблема не поспроизводится нигде, кроме как на машине у того самого заказчика. Teamviewer к машине не дают, студию и прочие нужные тулзы поставить тоже нельзя, передеплоить новую версию приложения или нельзя вообще или не получается сделать быстро, а разобраться и пофиксить надо уже вчера.

    В наших общих интересах, в создавшихся условиях, собрать максимально объективную информацию о возникновении проблемы и максимально быстро её поправить. Наша служба поддержки в таких случаях пытается объяснить, какая инфа нужна, и как её собрать (те же дампы и прочее). Это все занимает время, и немало. Для экономии времени, они просили нас сделать им тулзу, которая автоматически сделает всё, что необходимо. От программиста лишь требуется дописать батник для запуска его приложения и отправить заказчику вместе с тулзой. От заказчика требуется лишь скачать и запустить в один клик.

    Предлагаемые вами сбор дампов руками заказчика и прочие манипуляции, которые надо провести его силами, вполне применимы и возможны, но обычно уходит довольно много времени, чтобы объяснить заказчику, что и как ему делать. А наша служба подддержки тут в лучшем случае 3я в цепочке заказчик <-> программист <-> devexpress.


    1. asedovski
      15.12.2017 17:26

      Ну если ВАША служба поддержки занимается ТАКИМИ ситуациями (у клиента клиента что-то не работает почините ибо это вы виноваты а мы даже callstack достать не можем), то во-первых мое Вам уважение (я сталкивался с дугими службами поддержки), а во-вторых — я понял к чему эта картинка в начале :)

      Может следует добавить возможность логироания в компоненты, активируемую например параметром в конфиг файле, или фактом наличия файла или ключем в реестре уж если совсем все плохо и самим создавать нерадивым клиентам дампы :)…


      1. mviorno Автор
        15.12.2017 20:31

        Бывают именно такие ситуации, не каждый день, конечно. Когда к нам приходят с обоснованными подозрениями, что виноваты наши компоненты (например, перекомпилили с новой их версией и стало ломаться; но не воспроизводится ни у автора программы, ни у нас), нам по-любому надо докопаться до причины и уже править или на нашей стороне, и/или объяснить автору, как поправить на его стороне.

        Иногда просто пытаются перекинуть свои проблемы на нас (особенно этим индусы грешат), и даже в этом случае стараемся сначала добыть объективную информацию, и на её основании уже вежливо указать клиенту, где он неправ.

        Логировать было бы неплохо, но тут надо волевое политическое решение принимать, на уровне фирмы.


    1. asedovski
      15.12.2017 17:39

      И приучать своих клиентов (особенно тех кто именует себя программистами) писать в тех. поддержку с вложением тестового проекта где ошибка сразу воспроизводится. Ибо весь мой опыт говорит что если ошибка есть в приложении но ее нет в тестовом проекте (пустой проект где где есть компонент и минимум логики) то вероятность проблемы в компоненте равна вероятности ошибки в компиляторе и практически нулевая…


      1. mviorno Автор
        15.12.2017 20:41

        Приучать — вообще бесполезняк, грамотные люди и сами проект прилагают, у остальных приходится его выцарапывать. Мне больше всего нравится подход, когда на заявление «у меня падает», мы сами отдаём клиенту работающий тестовый проект с комментарем в духе «вот это у нас работает, попробуй у себя и, если не валится, добавь код, чтобы упало». Очень часто после такого ответа человек самостоятельно находит и фиксит проблему в своём коде. И это всё за один раунд переписки.

        А больше всего радуют (но нечасто) реально продвинутые ребята, которые приходят с проблемой типа «в методе X класса Y у вас в N-ной строке косяк, надо исправить вот так. поправьте плиз, чтобы мне из исходников ваши компоненты не собирать». Или, «я хочу что-то этакое закастомизить, почти всё сделал, но не хватает virtual в методe X класса Y, чтобы перекрыть и получить искомое. впишите, не сочтите за труд.»